From 83dfb068ad8bb4052787d80ca415118a20849b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E7=A1=95?= Date: Sat, 28 Mar 2026 10:36:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20open-source=20lark-cli=20=E2=80=94=20th?= =?UTF-8?q?e=20official=20CLI=20for=20Lark/Feishu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change-Id: I113d9cdb5403cec347efe4595415e34a18b7decf --- .github/workflows/coverage.yml | 36 + .github/workflows/lint.yml | 72 + .github/workflows/release.yml | 35 + .github/workflows/tests.yml | 30 + .gitignore | 32 + .goreleaser.yml | 40 + ...ate_Contributor_License_Agreement_v1.1.pdf | Bin 0 -> 127334 bytes CHANGELOG.md | 57 + CLA.md | 28 + LICENSE | 21 + Makefile | 39 + README.md | 268 + README.zh.md | 269 + build.sh | 9 + cmd/api/api.go | 247 + cmd/api/api_test.go | 558 ++ cmd/auth/auth.go | 143 + cmd/auth/auth_test.go | 233 + cmd/auth/check.go | 90 + cmd/auth/list.go | 71 + cmd/auth/login.go | 475 ++ cmd/auth/login_interactive.go | 207 + cmd/auth/login_messages.go | 108 + cmd/auth/login_messages_test.go | 96 + cmd/auth/login_test.go | 292 + cmd/auth/logout.go | 66 + cmd/auth/scopes.go | 74 + cmd/auth/status.go | 131 + cmd/completion/completion.go | 41 + cmd/config/config.go | 32 + cmd/config/config_test.go | 159 + cmd/config/default_as.go | 51 + cmd/config/init.go | 305 + cmd/config/init_interactive.go | 227 + cmd/config/init_messages.go | 84 + cmd/config/init_messages_test.go | 83 + cmd/config/remove.go | 69 + cmd/config/show.go | 66 + cmd/doctor/doctor.go | 235 + cmd/doctor/doctor_test.go | 97 + cmd/root.go | 307 + cmd/root_test.go | 189 + cmd/schema/schema.go | 500 ++ cmd/schema/schema_test.go | 63 + cmd/service/service.go | 432 ++ cmd/service/service_test.go | 552 ++ go.mod | 54 + go.sum | 155 + internal/auth/app_registration.go | 225 + internal/auth/app_registration_test.go | 32 + internal/auth/device_flow.go | 287 + internal/auth/device_flow_test.go | 30 + internal/auth/errors.go | 56 + internal/auth/scope.go | 22 + internal/auth/scope_test.go | 78 + internal/auth/token_store.go | 78 + internal/auth/transport.go | 199 + internal/auth/uat_client.go | 305 + internal/auth/uat_client_options_test.go | 39 + internal/auth/verify.go | 39 + internal/auth/verify_test.go | 88 + internal/build/build.go | 23 + internal/client/client.go | 270 + internal/client/client_test.go | 356 ++ internal/client/pagination.go | 68 + internal/client/response.go | 188 + internal/client/response_test.go | 334 + internal/cmdutil/annotations.go | 26 + internal/cmdutil/annotations_test.go | 51 + internal/cmdutil/dryrun.go | 252 + internal/cmdutil/dryrun_test.go | 178 + internal/cmdutil/factory.go | 141 + internal/cmdutil/factory_default.go | 113 + internal/cmdutil/factory_http_test.go | 44 + internal/cmdutil/factory_test.go | 282 + internal/cmdutil/identity.go | 43 + internal/cmdutil/identity_test.go | 101 + internal/cmdutil/iostreams.go | 16 + internal/cmdutil/json.go | 40 + internal/cmdutil/json_test.go | 66 + internal/cmdutil/retry_transport_test.go | 81 + internal/cmdutil/secheader.go | 81 + internal/cmdutil/testing.go | 66 + internal/cmdutil/testing_test.go | 61 + internal/cmdutil/theme.go | 58 + internal/cmdutil/tips.go | 47 + internal/cmdutil/tips_test.go | 59 + internal/cmdutil/transport.go | 100 + internal/core/config.go | 141 + internal/core/config_test.go | 74 + internal/core/errors.go | 22 + internal/core/secret.go | 86 + internal/core/secret_resolve.go | 61 + internal/core/types.go | 44 + internal/core/types_test.go | 48 + internal/httpmock/registry.go | 137 + internal/httpmock/registry_test.go | 114 + internal/keychain/default.go | 30 + internal/keychain/keychain.go | 39 + internal/keychain/keychain_darwin.go | 179 + internal/keychain/keychain_other.go | 176 + internal/keychain/keychain_windows.go | 170 + internal/lockfile/lock_unix.go | 24 + internal/lockfile/lock_windows.go | 57 + internal/lockfile/lockfile.go | 91 + internal/lockfile/lockfile_test.go | 197 + internal/output/colors.go | 14 + internal/output/csv.go | 76 + internal/output/csv_test.go | 152 + internal/output/envelope.go | 36 + internal/output/errors.go | 134 + internal/output/errors_test.go | 39 + internal/output/exitcode.go | 16 + internal/output/flatten.go | 167 + internal/output/flatten_test.go | 162 + internal/output/format.go | 195 + internal/output/format_test.go | 301 + internal/output/format_type.go | 48 + internal/output/format_type_test.go | 69 + internal/output/lark_errors.go | 66 + internal/output/print.go | 95 + internal/output/table.go | 130 + internal/output/table_test.go | 162 + internal/registry/helpers.go | 72 + internal/registry/loader.go | 385 ++ internal/registry/loader_embedded.go | 20 + internal/registry/meta_data_default.json | 1 + internal/registry/registry_test.go | 557 ++ internal/registry/remote.go | 311 + internal/registry/remote_test.go | 480 ++ internal/registry/scope_overrides.json | 50 + internal/registry/scope_priorities.json | 5522 +++++++++++++++++ internal/registry/scopes.go | 384 ++ internal/registry/service_desc.go | 78 + internal/registry/service_descriptions.json | 58 + internal/util/json.go | 25 + internal/util/reflect.go | 33 + internal/util/reflect_test.go | 73 + internal/util/strings.go | 25 + internal/validate/atomicwrite.go | 75 + internal/validate/atomicwrite_test.go | 146 + internal/validate/input.go | 70 + internal/validate/input_test.go | 85 + internal/validate/path.go | 128 + internal/validate/path_test.go | 285 + internal/validate/resource.go | 60 + internal/validate/resource_test.go | 108 + internal/validate/sanitize.go | 44 + internal/validate/sanitize_test.go | 89 + internal/validate/url.go | 212 + main.go | 15 + package.json | 33 + scripts/fetch_meta.py | 82 + scripts/install.js | 100 + scripts/run.js | 12 + scripts/tag-release.sh | 51 + shortcuts/base/base_advperm_disable.go | 56 + shortcuts/base/base_advperm_enable.go | 56 + shortcuts/base/base_advperm_test.go | 238 + shortcuts/base/base_command_common.go | 30 + shortcuts/base/base_copy.go | 30 + shortcuts/base/base_create.go | 28 + shortcuts/base/base_dashboard_execute_test.go | 625 ++ shortcuts/base/base_data_query.go | 65 + shortcuts/base/base_dryrun_ops_test.go | 220 + shortcuts/base/base_errors.go | 101 + shortcuts/base/base_errors_test.go | 60 + shortcuts/base/base_execute_test.go | 1047 ++++ shortcuts/base/base_form_create.go | 62 + shortcuts/base/base_form_delete.go | 46 + shortcuts/base/base_form_execute_test.go | 364 ++ shortcuts/base/base_form_get.go | 56 + shortcuts/base/base_form_list.go | 91 + shortcuts/base/base_form_questions_create.go | 73 + shortcuts/base/base_form_questions_delete.go | 59 + shortcuts/base/base_form_questions_list.go | 72 + shortcuts/base/base_form_questions_update.go | 76 + shortcuts/base/base_form_update.go | 68 + shortcuts/base/base_get.go | 24 + shortcuts/base/base_ops.go | 97 + shortcuts/base/base_role_common.go | 100 + shortcuts/base/base_role_create.go | 64 + shortcuts/base/base_role_delete.go | 59 + shortcuts/base/base_role_get.go | 59 + shortcuts/base/base_role_list.go | 53 + shortcuts/base/base_role_test.go | 608 ++ shortcuts/base/base_role_update.go | 71 + shortcuts/base/base_shortcut_helpers.go | 137 + shortcuts/base/base_shortcuts_test.go | 260 + shortcuts/base/dashboard_block_create.go | 79 + shortcuts/base/dashboard_block_delete.go | 35 + shortcuts/base/dashboard_block_get.go | 42 + shortcuts/base/dashboard_block_list.go | 44 + shortcuts/base/dashboard_block_update.go | 76 + shortcuts/base/dashboard_create.go | 41 + shortcuts/base/dashboard_delete.go | 33 + shortcuts/base/dashboard_get.go | 33 + shortcuts/base/dashboard_list.go | 42 + shortcuts/base/dashboard_ops.go | 303 + shortcuts/base/dashboard_update.go | 43 + shortcuts/base/field_create.go | 32 + shortcuts/base/field_delete.go | 24 + shortcuts/base/field_get.go | 24 + shortcuts/base/field_list.go | 29 + shortcuts/base/field_ops.go | 222 + shortcuts/base/field_search_options.go | 31 + shortcuts/base/field_update.go | 33 + shortcuts/base/helpers.go | 1129 ++++ shortcuts/base/helpers_test.go | 452 ++ shortcuts/base/record_delete.go | 24 + shortcuts/base/record_get.go | 28 + shortcuts/base/record_history_list.go | 43 + shortcuts/base/record_list.go | 30 + shortcuts/base/record_ops.go | 138 + shortcuts/base/record_upload_attachment.go | 261 + shortcuts/base/record_upsert.go | 32 + shortcuts/base/shortcuts.go | 80 + shortcuts/base/table_create.go | 32 + shortcuts/base/table_delete.go | 24 + shortcuts/base/table_get.go | 24 + shortcuts/base/table_list.go | 28 + shortcuts/base/table_ops.go | 216 + shortcuts/base/table_update.go | 28 + shortcuts/base/view_create.go | 31 + shortcuts/base/view_delete.go | 24 + shortcuts/base/view_get.go | 24 + shortcuts/base/view_get_card.go | 24 + shortcuts/base/view_get_filter.go | 24 + shortcuts/base/view_get_group.go | 24 + shortcuts/base/view_get_sort.go | 24 + shortcuts/base/view_get_timebar.go | 24 + shortcuts/base/view_list.go | 29 + shortcuts/base/view_ops.go | 256 + shortcuts/base/view_rename.go | 29 + shortcuts/base/view_set_card.go | 32 + shortcuts/base/view_set_filter.go | 32 + shortcuts/base/view_set_group.go | 32 + shortcuts/base/view_set_sort.go | 32 + shortcuts/base/view_set_timebar.go | 32 + shortcuts/base/workflow_create.go | 67 + shortcuts/base/workflow_disable.go | 51 + shortcuts/base/workflow_enable.go | 51 + shortcuts/base/workflow_execute_test.go | 138 + shortcuts/base/workflow_get.go | 60 + shortcuts/base/workflow_list.go | 82 + shortcuts/base/workflow_update.go | 72 + shortcuts/calendar/calendar_agenda.go | 294 + shortcuts/calendar/calendar_create.go | 283 + shortcuts/calendar/calendar_freebusy.go | 129 + shortcuts/calendar/calendar_suggestion.go | 337 + shortcuts/calendar/calendar_test.go | 893 +++ shortcuts/calendar/helpers.go | 28 + shortcuts/calendar/shortcuts.go | 16 + shortcuts/common/common.go | 200 + shortcuts/common/common_test.go | 88 + shortcuts/common/dryrun.go | 13 + shortcuts/common/extract.go | 93 + shortcuts/common/extract_test.go | 156 + shortcuts/common/helpers.go | 51 + shortcuts/common/mcp_client.go | 254 + shortcuts/common/mcp_client_test.go | 207 + shortcuts/common/pagination.go | 25 + shortcuts/common/pagination_test.go | 87 + shortcuts/common/runner.go | 697 +++ shortcuts/common/runner_scope_test.go | 166 + shortcuts/common/sanitize.go | 23 + shortcuts/common/testing.go | 24 + shortcuts/common/types.go | 56 + shortcuts/common/types_test.go | 73 + shortcuts/common/validate.go | 141 + shortcuts/common/validate_ids.go | 46 + shortcuts/common/validate_test.go | 247 + shortcuts/contact/contact_get_user.go | 136 + shortcuts/contact/contact_search_user.go | 140 + shortcuts/contact/shortcuts.go | 14 + shortcuts/doc/doc_media_download.go | 130 + shortcuts/doc/doc_media_insert.go | 422 ++ shortcuts/doc/doc_media_insert_test.go | 163 + shortcuts/doc/doc_media_test.go | 209 + shortcuts/doc/doc_media_upload.go | 137 + shortcuts/doc/docs_create.go | 89 + shortcuts/doc/docs_fetch.go | 77 + shortcuts/doc/docs_search.go | 306 + shortcuts/doc/docs_search_test.go | 102 + shortcuts/doc/docs_update.go | 158 + shortcuts/doc/docs_update_test.go | 78 + shortcuts/doc/helpers.go | 65 + shortcuts/doc/helpers_test.go | 90 + shortcuts/doc/shortcuts.go | 18 + shortcuts/drive/drive_add_comment.go | 593 ++ shortcuts/drive/drive_add_comment_test.go | 302 + shortcuts/drive/drive_download.go | 89 + shortcuts/drive/drive_io_test.go | 159 + shortcuts/drive/drive_upload.go | 140 + shortcuts/drive/shortcuts.go | 15 + shortcuts/event/filter.go | 107 + shortcuts/event/helpers.go | 50 + shortcuts/event/pipeline.go | 198 + shortcuts/event/processor.go | 62 + shortcuts/event/processor_generic.go | 38 + shortcuts/event/processor_im_chat.go | 101 + shortcuts/event/processor_im_chat_member.go | 144 + shortcuts/event/processor_im_message.go | 102 + .../event/processor_im_message_reaction.go | 82 + shortcuts/event/processor_im_message_read.go | 56 + shortcuts/event/processor_im_test.go | 501 ++ shortcuts/event/processor_test.go | 927 +++ shortcuts/event/registry.go | 59 + shortcuts/event/router.go | 76 + shortcuts/event/shortcuts.go | 13 + shortcuts/event/subscribe.go | 294 + shortcuts/im/builders_test.go | 633 ++ shortcuts/im/convert_lib/card.go | 1548 +++++ shortcuts/im/convert_lib/card_test.go | 341 + shortcuts/im/convert_lib/content_convert.go | 190 + .../im/convert_lib/content_media_misc_test.go | 144 + shortcuts/im/convert_lib/helpers.go | 265 + shortcuts/im/convert_lib/helpers_test.go | 201 + shortcuts/im/convert_lib/media.go | 75 + shortcuts/im/convert_lib/merge.go | 209 + shortcuts/im/convert_lib/merge_test.go | 155 + shortcuts/im/convert_lib/misc.go | 291 + shortcuts/im/convert_lib/runtime_test.go | 80 + shortcuts/im/convert_lib/text.go | 140 + shortcuts/im/convert_lib/text_test.go | 112 + shortcuts/im/convert_lib/thread.go | 110 + shortcuts/im/convert_lib/thread_test.go | 132 + shortcuts/im/coverage_additional_test.go | 505 ++ shortcuts/im/helpers.go | 970 +++ shortcuts/im/helpers_network_test.go | 504 ++ shortcuts/im/helpers_test.go | 513 ++ shortcuts/im/im_chat_create.go | 159 + shortcuts/im/im_chat_messages_list.go | 213 + shortcuts/im/im_chat_search.go | 221 + shortcuts/im/im_chat_update.go | 97 + shortcuts/im/im_messages_mget.go | 104 + shortcuts/im/im_messages_reply.go | 214 + .../im/im_messages_resources_download.go | 144 + shortcuts/im/im_messages_search.go | 348 ++ shortcuts/im/im_messages_send.go | 239 + shortcuts/im/im_threads_messages_list.go | 165 + shortcuts/im/shortcuts.go | 22 + shortcuts/mail/address.go | 114 + shortcuts/mail/draft/acceptance_test.go | 166 + shortcuts/mail/draft/charset.go | 64 + shortcuts/mail/draft/htmltext.go | 141 + shortcuts/mail/draft/htmltext_test.go | 104 + shortcuts/mail/draft/limits.go | 64 + shortcuts/mail/draft/limits_test.go | 73 + shortcuts/mail/draft/model.go | 341 + shortcuts/mail/draft/model_test.go | 356 ++ shortcuts/mail/draft/parse.go | 509 ++ shortcuts/mail/draft/parse_extra_test.go | 226 + shortcuts/mail/draft/parse_test.go | 507 ++ shortcuts/mail/draft/patch.go | 915 +++ shortcuts/mail/draft/patch_attachment_test.go | 494 ++ shortcuts/mail/draft/patch_body_test.go | 365 ++ shortcuts/mail/draft/patch_header_test.go | 207 + shortcuts/mail/draft/patch_recipient_test.go | 292 + shortcuts/mail/draft/patch_test.go | 695 +++ shortcuts/mail/draft/projection.go | 149 + shortcuts/mail/draft/projection_extra_test.go | 110 + shortcuts/mail/draft/projection_test.go | 212 + shortcuts/mail/draft/serialize.go | 337 + shortcuts/mail/draft/serialize_golden_test.go | 110 + shortcuts/mail/draft/serialize_test.go | 260 + shortcuts/mail/draft/service.go | 92 + .../alternative_append_text.golden.eml | 18 + .../mail/draft/testdata/alternative_draft.eml | 17 + .../testdata/alternative_set_body.golden.eml | 17 + .../mail/draft/testdata/calendar_draft.eml | 23 + .../draft/testdata/custom_header_draft.eml | 10 + .../custom_header_set_subject.golden.eml | 10 + .../testdata/dirty_multipart_preamble.eml | 24 + .../mail/draft/testdata/forward_draft.eml | 24 + .../forward_remove_attachment.golden.eml | 18 + .../mail/draft/testdata/html_inline_draft.eml | 19 + .../testdata/html_inline_remove.golden.eml | 12 + .../testdata/html_inline_replace.golden.eml | 19 + .../html_inline_replace_binary.golden.eml | 19 + .../draft/testdata/message_rfc822_draft.eml | 24 + .../draft/testdata/multipart_signed_draft.eml | 18 + shortcuts/mail/draft/testdata/reply_draft.eml | 11 + .../testdata/reply_draft_subject.golden.eml | 11 + .../reply_draft_with_inline_attachment.eml | 28 + shortcuts/mail/emlbuilder/builder.go | 951 +++ shortcuts/mail/emlbuilder/builder_test.go | 1083 ++++ shortcuts/mail/filecheck/filecheck.go | 171 + shortcuts/mail/filecheck/filecheck_test.go | 124 + shortcuts/mail/helpers.go | 1892 ++++++ shortcuts/mail/helpers_test.go | 901 +++ shortcuts/mail/limits.go | 26 + shortcuts/mail/mail_draft_create.go | 173 + shortcuts/mail/mail_draft_edit.go | 378 ++ shortcuts/mail/mail_forward.go | 213 + shortcuts/mail/mail_message.go | 53 + shortcuts/mail/mail_messages.go | 78 + shortcuts/mail/mail_quote.go | 585 ++ shortcuts/mail/mail_quote_test.go | 389 ++ shortcuts/mail/mail_reply.go | 174 + shortcuts/mail/mail_reply_all.go | 273 + shortcuts/mail/mail_send.go | 159 + shortcuts/mail/mail_shortcut_test.go | 104 + shortcuts/mail/mail_thread.go | 116 + shortcuts/mail/mail_triage.go | 992 +++ shortcuts/mail/mail_triage_test.go | 970 +++ shortcuts/mail/mail_watch.go | 789 +++ shortcuts/mail/mail_watch_test.go | 637 ++ shortcuts/mail/shortcuts.go | 23 + shortcuts/register.go | 84 + shortcuts/register_test.go | 90 + shortcuts/sheets/helpers.go | 206 + shortcuts/sheets/sheet_append.go | 99 + shortcuts/sheets/sheet_create.go | 111 + shortcuts/sheets/sheet_export.go | 146 + shortcuts/sheets/sheet_find.go | 101 + shortcuts/sheets/sheet_info.go | 73 + shortcuts/sheets/sheet_read.go | 88 + shortcuts/sheets/sheet_write.go | 99 + shortcuts/sheets/shortcuts.go | 19 + shortcuts/task/shortcuts.go | 237 + shortcuts/task/shortcuts_test.go | 17 + shortcuts/task/task_assign.go | 167 + shortcuts/task/task_assign_test.go | 19 + shortcuts/task/task_comment.go | 89 + shortcuts/task/task_complete.go | 104 + shortcuts/task/task_followers.go | 163 + shortcuts/task/task_followers_test.go | 19 + shortcuts/task/task_get_my_tasks.go | 287 + shortcuts/task/task_reminder.go | 240 + shortcuts/task/task_reopen.go | 102 + shortcuts/task/task_update.go | 177 + shortcuts/task/task_util.go | 210 + shortcuts/task/task_util_test.go | 19 + shortcuts/task/tasklist_add_task.go | 143 + shortcuts/task/tasklist_create.go | 223 + shortcuts/task/tasklist_members.go | 333 + shortcuts/task/tasklist_members_test.go | 18 + .../transcript.txt | 1 + .../transcript.txt | 1 + .../transcript.txt | 1 + shortcuts/vc/shortcuts.go | 14 + shortcuts/vc/vc_notes.go | 599 ++ shortcuts/vc/vc_notes_test.go | 381 ++ shortcuts/vc/vc_search.go | 272 + shortcuts/vc/vc_search_test.go | 261 + shortcuts/whiteboard/shortcuts.go | 25 + shortcuts/whiteboard/whiteboard_update.go | 251 + skill-template/domains/base.md | 117 + skill-template/domains/calendar.md | 15 + skill-template/domains/doc.md | 114 + skill-template/domains/drive.md | 143 + skill-template/domains/im.md | 37 + skill-template/domains/mail.md | 172 + skill-template/domains/sheets.md | 129 + skill-template/domains/vc.md | 26 + skill-template/master-skill-template.md | 30 + skill-template/skill-template.md | 41 + skills/lark-base/SKILL.md | 309 + .../references/dashboard-block-data-config.md | 170 + skills/lark-base/references/examples.md | 130 + .../references/formula-field-guide.md | 735 +++ .../references/lark-base-advperm-disable.md | 83 + .../references/lark-base-advperm-enable.md | 80 + .../references/lark-base-base-copy.md | 79 + .../references/lark-base-base-create.md | 73 + .../references/lark-base-base-get.md | 39 + .../lark-base-dashboard-block-create.md | 106 + .../lark-base-dashboard-block-delete.md | 40 + .../lark-base-dashboard-block-get.md | 58 + .../lark-base-dashboard-block-list.md | 49 + .../lark-base-dashboard-block-update.md | 64 + .../references/lark-base-dashboard-block.md | 25 + .../references/lark-base-dashboard-create.md | 76 + .../references/lark-base-dashboard-delete.md | 42 + .../references/lark-base-dashboard-get.md | 48 + .../references/lark-base-dashboard-list.md | 43 + .../references/lark-base-dashboard-update.md | 67 + .../references/lark-base-dashboard.md | 24 + .../references/lark-base-data-query.md | 375 ++ .../references/lark-base-field-create.md | 88 + .../references/lark-base-field-delete.md | 51 + .../references/lark-base-field-get.md | 42 + .../references/lark-base-field-list.md | 44 + .../lark-base-field-search-options.md | 48 + .../references/lark-base-field-update.md | 80 + .../lark-base/references/lark-base-field.md | 22 + .../references/lark-base-form-create.md | 87 + .../references/lark-base-form-delete.md | 64 + .../references/lark-base-form-get.md | 68 + .../references/lark-base-form-list.md | 73 + .../lark-base-form-questions-create.md | 118 + .../lark-base-form-questions-delete.md | 68 + .../lark-base-form-questions-list.md | 84 + .../lark-base-form-questions-update.md | 92 + .../references/lark-base-form-questions.md | 23 + .../references/lark-base-form-update.md | 82 + skills/lark-base/references/lark-base-form.md | 24 + .../lark-base/references/lark-base-history.md | 16 + .../references/lark-base-record-delete.md | 51 + .../references/lark-base-record-get.md | 46 + .../lark-base-record-history-list.md | 86 + .../references/lark-base-record-list.md | 54 + .../lark-base-record-upload-attachment.md | 50 + .../references/lark-base-record-upsert.md | 89 + .../lark-base/references/lark-base-record.md | 22 + .../references/lark-base-role-create.md | 89 + .../references/lark-base-role-delete.md | 83 + .../references/lark-base-role-get.md | 87 + .../references/lark-base-role-list.md | 81 + .../references/lark-base-role-update.md | 94 + .../lark-base-shortcut-field-properties.md | 661 ++ .../lark-base-shortcut-record-value.md | 200 + .../references/lark-base-table-create.md | 62 + .../references/lark-base-table-delete.md | 51 + .../references/lark-base-table-get.md | 45 + .../references/lark-base-table-list.md | 43 + .../references/lark-base-table-update.md | 49 + .../lark-base/references/lark-base-table.md | 20 + .../references/lark-base-view-create.md | 81 + .../references/lark-base-view-delete.md | 48 + .../references/lark-base-view-get-card.md | 38 + .../references/lark-base-view-get-filter.md | 38 + .../references/lark-base-view-get-group.md | 38 + .../references/lark-base-view-get-sort.md | 38 + .../references/lark-base-view-get-timebar.md | 38 + .../references/lark-base-view-get.md | 38 + .../references/lark-base-view-list.md | 44 + .../references/lark-base-view-rename.md | 44 + .../references/lark-base-view-set-card.md | 81 + .../references/lark-base-view-set-filter.md | 92 + .../references/lark-base-view-set-group.md | 77 + .../references/lark-base-view-set-sort.md | 77 + .../references/lark-base-view-set-timebar.md | 79 + skills/lark-base/references/lark-base-view.md | 42 + .../references/lark-base-workflow-create.md | 160 + .../references/lark-base-workflow-disable.md | 94 + .../references/lark-base-workflow-enable.md | 94 + .../references/lark-base-workflow-get.md | 149 + .../references/lark-base-workflow-list.md | 111 + .../references/lark-base-workflow-schema.md | 920 +++ .../references/lark-base-workflow-update.md | 168 + .../references/lark-base-workflow.md | 23 + .../references/lark-base-workspace.md | 18 + .../references/lookup-field-guide.md | 508 ++ skills/lark-base/references/role-config.md | 532 ++ skills/lark-calendar/SKILL.md | 153 + .../references/lark-calendar-agenda.md | 78 + .../references/lark-calendar-create.md | 107 + .../references/lark-calendar-freebusy.md | 124 + .../references/lark-calendar-suggestion.md | 126 + skills/lark-contact/SKILL.md | 23 + .../references/lark-contact-get-user.md | 47 + .../references/lark-contact-search-user.md | 39 + skills/lark-doc/SKILL.md | 142 + skills/lark-doc/references/lark-doc-create.md | 657 ++ skills/lark-doc/references/lark-doc-fetch.md | 99 + .../references/lark-doc-media-download.md | 39 + .../references/lark-doc-media-insert.md | 51 + skills/lark-doc/references/lark-doc-search.md | 75 + skills/lark-doc/references/lark-doc-update.md | 261 + .../references/lark-doc-whiteboard-update.md | 19 + skills/lark-drive/SKILL.md | 231 + .../references/lark-drive-add-comment.md | 99 + .../references/lark-drive-download.md | 31 + .../references/lark-drive-upload.md | 71 + skills/lark-event/SKILL.md | 21 + .../references/lark-event-subscribe.md | 215 + skills/lark-im/SKILL.md | 139 + .../lark-im/references/lark-im-chat-create.md | 136 + .../references/lark-im-chat-identity.md | 55 + .../references/lark-im-chat-messages-list.md | 141 + .../lark-im/references/lark-im-chat-search.md | 114 + .../lark-im/references/lark-im-chat-update.md | 84 + .../references/lark-im-messages-mget.md | 95 + .../references/lark-im-messages-reply.md | 123 + .../lark-im-messages-resources-download.md | 77 + .../references/lark-im-messages-search.md | 193 + .../references/lark-im-messages-send.md | 120 + .../lark-im/references/lark-im-reactions.md | 297 + .../lark-im-threads-messages-list.md | 111 + skills/lark-mail/SKILL.md | 326 + .../references/lark-mail-draft-create.md | 100 + .../references/lark-mail-draft-edit.md | 355 ++ .../lark-mail/references/lark-mail-forward.md | 172 + .../lark-mail/references/lark-mail-message.md | 199 + .../references/lark-mail-messages.md | 107 + .../references/lark-mail-reply-all.md | 148 + .../lark-mail/references/lark-mail-reply.md | 184 + skills/lark-mail/references/lark-mail-send.md | 142 + .../lark-mail/references/lark-mail-thread.md | 110 + .../lark-mail/references/lark-mail-triage.md | 86 + .../lark-mail/references/lark-mail-watch.md | 94 + skills/lark-minutes/SKILL.md | 72 + skills/lark-openapi-explorer/SKILL.md | 153 + skills/lark-shared/SKILL.md | 80 + skills/lark-sheets/SKILL.md | 196 + .../references/lark-sheets-append.md | 56 + .../references/lark-sheets-create.md | 72 + .../references/lark-sheets-export.md | 51 + .../references/lark-sheets-find.md | 62 + .../references/lark-sheets-info.md | 43 + .../references/lark-sheets-read.md | 61 + .../references/lark-sheets-write.md | 60 + skills/lark-skill-maker/SKILL.md | 85 + skills/lark-task/SKILL.md | 94 + .../lark-task/references/lark-task-assign.md | 36 + .../lark-task/references/lark-task-comment.md | 28 + .../references/lark-task-complete.md | 27 + .../lark-task/references/lark-task-create.md | 51 + .../references/lark-task-followers.md | 32 + .../references/lark-task-get-my-tasks.md | 44 + .../references/lark-task-reminder.md | 36 + .../lark-task/references/lark-task-reopen.md | 27 + .../references/lark-task-tasklist-create.md | 35 + .../references/lark-task-tasklist-members.md | 36 + .../references/lark-task-tasklist-task-add.md | 31 + .../lark-task/references/lark-task-update.md | 37 + skills/lark-vc/SKILL.md | 117 + skills/lark-vc/references/lark-vc-notes.md | 123 + skills/lark-vc/references/lark-vc-search.md | 144 + skills/lark-whiteboard/SKILL.md | 214 + .../lark-whiteboard/references/connectors.md | 92 + skills/lark-whiteboard/references/content.md | 40 + skills/lark-whiteboard/references/layout.md | 274 + skills/lark-whiteboard/references/schema.md | 250 + skills/lark-whiteboard/references/style.md | 301 + .../lark-whiteboard/references/typography.md | 67 + skills/lark-whiteboard/scenes/architecture.md | 432 ++ skills/lark-whiteboard/scenes/bar-chart.md | 188 + skills/lark-whiteboard/scenes/comparison.md | 135 + skills/lark-whiteboard/scenes/fishbone.md | 245 + skills/lark-whiteboard/scenes/flywheel.md | 202 + skills/lark-whiteboard/scenes/funnel.md | 101 + skills/lark-whiteboard/scenes/line-chart.md | 215 + skills/lark-whiteboard/scenes/mermaid.md | 132 + skills/lark-whiteboard/scenes/milestone.md | 139 + skills/lark-whiteboard/scenes/organization.md | 175 + skills/lark-whiteboard/scenes/pyramid.md | 99 + skills/lark-whiteboard/scenes/treemap.md | 217 + skills/lark-wiki/SKILL.md | 33 + skills/lark-workflow-meeting-summary/SKILL.md | 104 + skills/lark-workflow-standup-report/SKILL.md | 120 + 643 files changed, 101763 insertions(+) create mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yml create mode 100644 ByteDance_Corporate_Contributor_License_Agreement_v1.1.pdf create mode 100644 CHANGELOG.md create mode 100644 CLA.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 README.zh.md create mode 100755 build.sh create mode 100644 cmd/api/api.go create mode 100644 cmd/api/api_test.go create mode 100644 cmd/auth/auth.go create mode 100644 cmd/auth/auth_test.go create mode 100644 cmd/auth/check.go create mode 100644 cmd/auth/list.go create mode 100644 cmd/auth/login.go create mode 100644 cmd/auth/login_interactive.go create mode 100644 cmd/auth/login_messages.go create mode 100644 cmd/auth/login_messages_test.go create mode 100644 cmd/auth/login_test.go create mode 100644 cmd/auth/logout.go create mode 100644 cmd/auth/scopes.go create mode 100644 cmd/auth/status.go create mode 100644 cmd/completion/completion.go create mode 100644 cmd/config/config.go create mode 100644 cmd/config/config_test.go create mode 100644 cmd/config/default_as.go create mode 100644 cmd/config/init.go create mode 100644 cmd/config/init_interactive.go create mode 100644 cmd/config/init_messages.go create mode 100644 cmd/config/init_messages_test.go create mode 100644 cmd/config/remove.go create mode 100644 cmd/config/show.go create mode 100644 cmd/doctor/doctor.go create mode 100644 cmd/doctor/doctor_test.go create mode 100644 cmd/root.go create mode 100644 cmd/root_test.go create mode 100644 cmd/schema/schema.go create mode 100644 cmd/schema/schema_test.go create mode 100644 cmd/service/service.go create mode 100644 cmd/service/service_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/app_registration.go create mode 100644 internal/auth/app_registration_test.go create mode 100644 internal/auth/device_flow.go create mode 100644 internal/auth/device_flow_test.go create mode 100644 internal/auth/errors.go create mode 100644 internal/auth/scope.go create mode 100644 internal/auth/scope_test.go create mode 100644 internal/auth/token_store.go create mode 100644 internal/auth/transport.go create mode 100644 internal/auth/uat_client.go create mode 100644 internal/auth/uat_client_options_test.go create mode 100644 internal/auth/verify.go create mode 100644 internal/auth/verify_test.go create mode 100644 internal/build/build.go create mode 100644 internal/client/client.go create mode 100644 internal/client/client_test.go create mode 100644 internal/client/pagination.go create mode 100644 internal/client/response.go create mode 100644 internal/client/response_test.go create mode 100644 internal/cmdutil/annotations.go create mode 100644 internal/cmdutil/annotations_test.go create mode 100644 internal/cmdutil/dryrun.go create mode 100644 internal/cmdutil/dryrun_test.go create mode 100644 internal/cmdutil/factory.go create mode 100644 internal/cmdutil/factory_default.go create mode 100644 internal/cmdutil/factory_http_test.go create mode 100644 internal/cmdutil/factory_test.go create mode 100644 internal/cmdutil/identity.go create mode 100644 internal/cmdutil/identity_test.go create mode 100644 internal/cmdutil/iostreams.go create mode 100644 internal/cmdutil/json.go create mode 100644 internal/cmdutil/json_test.go create mode 100644 internal/cmdutil/retry_transport_test.go create mode 100644 internal/cmdutil/secheader.go create mode 100644 internal/cmdutil/testing.go create mode 100644 internal/cmdutil/testing_test.go create mode 100644 internal/cmdutil/theme.go create mode 100644 internal/cmdutil/tips.go create mode 100644 internal/cmdutil/tips_test.go create mode 100644 internal/cmdutil/transport.go create mode 100644 internal/core/config.go create mode 100644 internal/core/config_test.go create mode 100644 internal/core/errors.go create mode 100644 internal/core/secret.go create mode 100644 internal/core/secret_resolve.go create mode 100644 internal/core/types.go create mode 100644 internal/core/types_test.go create mode 100644 internal/httpmock/registry.go create mode 100644 internal/httpmock/registry_test.go create mode 100644 internal/keychain/default.go create mode 100644 internal/keychain/keychain.go create mode 100644 internal/keychain/keychain_darwin.go create mode 100644 internal/keychain/keychain_other.go create mode 100644 internal/keychain/keychain_windows.go create mode 100644 internal/lockfile/lock_unix.go create mode 100644 internal/lockfile/lock_windows.go create mode 100644 internal/lockfile/lockfile.go create mode 100644 internal/lockfile/lockfile_test.go create mode 100644 internal/output/colors.go create mode 100644 internal/output/csv.go create mode 100644 internal/output/csv_test.go create mode 100644 internal/output/envelope.go create mode 100644 internal/output/errors.go create mode 100644 internal/output/errors_test.go create mode 100644 internal/output/exitcode.go create mode 100644 internal/output/flatten.go create mode 100644 internal/output/flatten_test.go create mode 100644 internal/output/format.go create mode 100644 internal/output/format_test.go create mode 100644 internal/output/format_type.go create mode 100644 internal/output/format_type_test.go create mode 100644 internal/output/lark_errors.go create mode 100644 internal/output/print.go create mode 100644 internal/output/table.go create mode 100644 internal/output/table_test.go create mode 100644 internal/registry/helpers.go create mode 100644 internal/registry/loader.go create mode 100644 internal/registry/loader_embedded.go create mode 100644 internal/registry/meta_data_default.json create mode 100644 internal/registry/registry_test.go create mode 100644 internal/registry/remote.go create mode 100644 internal/registry/remote_test.go create mode 100644 internal/registry/scope_overrides.json create mode 100644 internal/registry/scope_priorities.json create mode 100644 internal/registry/scopes.go create mode 100644 internal/registry/service_desc.go create mode 100644 internal/registry/service_descriptions.json create mode 100644 internal/util/json.go create mode 100644 internal/util/reflect.go create mode 100644 internal/util/reflect_test.go create mode 100644 internal/util/strings.go create mode 100644 internal/validate/atomicwrite.go create mode 100644 internal/validate/atomicwrite_test.go create mode 100644 internal/validate/input.go create mode 100644 internal/validate/input_test.go create mode 100644 internal/validate/path.go create mode 100644 internal/validate/path_test.go create mode 100644 internal/validate/resource.go create mode 100644 internal/validate/resource_test.go create mode 100644 internal/validate/sanitize.go create mode 100644 internal/validate/sanitize_test.go create mode 100644 internal/validate/url.go create mode 100644 main.go create mode 100644 package.json create mode 100644 scripts/fetch_meta.py create mode 100644 scripts/install.js create mode 100644 scripts/run.js create mode 100755 scripts/tag-release.sh create mode 100644 shortcuts/base/base_advperm_disable.go create mode 100644 shortcuts/base/base_advperm_enable.go create mode 100644 shortcuts/base/base_advperm_test.go create mode 100644 shortcuts/base/base_command_common.go create mode 100644 shortcuts/base/base_copy.go create mode 100644 shortcuts/base/base_create.go create mode 100644 shortcuts/base/base_dashboard_execute_test.go create mode 100644 shortcuts/base/base_data_query.go create mode 100644 shortcuts/base/base_dryrun_ops_test.go create mode 100644 shortcuts/base/base_errors.go create mode 100644 shortcuts/base/base_errors_test.go create mode 100644 shortcuts/base/base_execute_test.go create mode 100644 shortcuts/base/base_form_create.go create mode 100644 shortcuts/base/base_form_delete.go create mode 100644 shortcuts/base/base_form_execute_test.go create mode 100644 shortcuts/base/base_form_get.go create mode 100644 shortcuts/base/base_form_list.go create mode 100644 shortcuts/base/base_form_questions_create.go create mode 100644 shortcuts/base/base_form_questions_delete.go create mode 100644 shortcuts/base/base_form_questions_list.go create mode 100644 shortcuts/base/base_form_questions_update.go create mode 100644 shortcuts/base/base_form_update.go create mode 100644 shortcuts/base/base_get.go create mode 100644 shortcuts/base/base_ops.go create mode 100644 shortcuts/base/base_role_common.go create mode 100644 shortcuts/base/base_role_create.go create mode 100644 shortcuts/base/base_role_delete.go create mode 100644 shortcuts/base/base_role_get.go create mode 100644 shortcuts/base/base_role_list.go create mode 100644 shortcuts/base/base_role_test.go create mode 100644 shortcuts/base/base_role_update.go create mode 100644 shortcuts/base/base_shortcut_helpers.go create mode 100644 shortcuts/base/base_shortcuts_test.go create mode 100644 shortcuts/base/dashboard_block_create.go create mode 100644 shortcuts/base/dashboard_block_delete.go create mode 100644 shortcuts/base/dashboard_block_get.go create mode 100644 shortcuts/base/dashboard_block_list.go create mode 100644 shortcuts/base/dashboard_block_update.go create mode 100644 shortcuts/base/dashboard_create.go create mode 100644 shortcuts/base/dashboard_delete.go create mode 100644 shortcuts/base/dashboard_get.go create mode 100644 shortcuts/base/dashboard_list.go create mode 100644 shortcuts/base/dashboard_ops.go create mode 100644 shortcuts/base/dashboard_update.go create mode 100644 shortcuts/base/field_create.go create mode 100644 shortcuts/base/field_delete.go create mode 100644 shortcuts/base/field_get.go create mode 100644 shortcuts/base/field_list.go create mode 100644 shortcuts/base/field_ops.go create mode 100644 shortcuts/base/field_search_options.go create mode 100644 shortcuts/base/field_update.go create mode 100644 shortcuts/base/helpers.go create mode 100644 shortcuts/base/helpers_test.go create mode 100644 shortcuts/base/record_delete.go create mode 100644 shortcuts/base/record_get.go create mode 100644 shortcuts/base/record_history_list.go create mode 100644 shortcuts/base/record_list.go create mode 100644 shortcuts/base/record_ops.go create mode 100644 shortcuts/base/record_upload_attachment.go create mode 100644 shortcuts/base/record_upsert.go create mode 100644 shortcuts/base/shortcuts.go create mode 100644 shortcuts/base/table_create.go create mode 100644 shortcuts/base/table_delete.go create mode 100644 shortcuts/base/table_get.go create mode 100644 shortcuts/base/table_list.go create mode 100644 shortcuts/base/table_ops.go create mode 100644 shortcuts/base/table_update.go create mode 100644 shortcuts/base/view_create.go create mode 100644 shortcuts/base/view_delete.go create mode 100644 shortcuts/base/view_get.go create mode 100644 shortcuts/base/view_get_card.go create mode 100644 shortcuts/base/view_get_filter.go create mode 100644 shortcuts/base/view_get_group.go create mode 100644 shortcuts/base/view_get_sort.go create mode 100644 shortcuts/base/view_get_timebar.go create mode 100644 shortcuts/base/view_list.go create mode 100644 shortcuts/base/view_ops.go create mode 100644 shortcuts/base/view_rename.go create mode 100644 shortcuts/base/view_set_card.go create mode 100644 shortcuts/base/view_set_filter.go create mode 100644 shortcuts/base/view_set_group.go create mode 100644 shortcuts/base/view_set_sort.go create mode 100644 shortcuts/base/view_set_timebar.go create mode 100644 shortcuts/base/workflow_create.go create mode 100644 shortcuts/base/workflow_disable.go create mode 100644 shortcuts/base/workflow_enable.go create mode 100644 shortcuts/base/workflow_execute_test.go create mode 100644 shortcuts/base/workflow_get.go create mode 100644 shortcuts/base/workflow_list.go create mode 100644 shortcuts/base/workflow_update.go create mode 100644 shortcuts/calendar/calendar_agenda.go create mode 100644 shortcuts/calendar/calendar_create.go create mode 100644 shortcuts/calendar/calendar_freebusy.go create mode 100644 shortcuts/calendar/calendar_suggestion.go create mode 100644 shortcuts/calendar/calendar_test.go create mode 100644 shortcuts/calendar/helpers.go create mode 100644 shortcuts/calendar/shortcuts.go create mode 100644 shortcuts/common/common.go create mode 100644 shortcuts/common/common_test.go create mode 100644 shortcuts/common/dryrun.go create mode 100644 shortcuts/common/extract.go create mode 100644 shortcuts/common/extract_test.go create mode 100644 shortcuts/common/helpers.go create mode 100644 shortcuts/common/mcp_client.go create mode 100644 shortcuts/common/mcp_client_test.go create mode 100644 shortcuts/common/pagination.go create mode 100644 shortcuts/common/pagination_test.go create mode 100644 shortcuts/common/runner.go create mode 100644 shortcuts/common/runner_scope_test.go create mode 100644 shortcuts/common/sanitize.go create mode 100644 shortcuts/common/testing.go create mode 100644 shortcuts/common/types.go create mode 100644 shortcuts/common/types_test.go create mode 100644 shortcuts/common/validate.go create mode 100644 shortcuts/common/validate_ids.go create mode 100644 shortcuts/common/validate_test.go create mode 100644 shortcuts/contact/contact_get_user.go create mode 100644 shortcuts/contact/contact_search_user.go create mode 100644 shortcuts/contact/shortcuts.go create mode 100644 shortcuts/doc/doc_media_download.go create mode 100644 shortcuts/doc/doc_media_insert.go create mode 100644 shortcuts/doc/doc_media_insert_test.go create mode 100644 shortcuts/doc/doc_media_test.go create mode 100644 shortcuts/doc/doc_media_upload.go create mode 100644 shortcuts/doc/docs_create.go create mode 100644 shortcuts/doc/docs_fetch.go create mode 100644 shortcuts/doc/docs_search.go create mode 100644 shortcuts/doc/docs_search_test.go create mode 100644 shortcuts/doc/docs_update.go create mode 100644 shortcuts/doc/docs_update_test.go create mode 100644 shortcuts/doc/helpers.go create mode 100644 shortcuts/doc/helpers_test.go create mode 100644 shortcuts/doc/shortcuts.go create mode 100644 shortcuts/drive/drive_add_comment.go create mode 100644 shortcuts/drive/drive_add_comment_test.go create mode 100644 shortcuts/drive/drive_download.go create mode 100644 shortcuts/drive/drive_io_test.go create mode 100644 shortcuts/drive/drive_upload.go create mode 100644 shortcuts/drive/shortcuts.go create mode 100644 shortcuts/event/filter.go create mode 100644 shortcuts/event/helpers.go create mode 100644 shortcuts/event/pipeline.go create mode 100644 shortcuts/event/processor.go create mode 100644 shortcuts/event/processor_generic.go create mode 100644 shortcuts/event/processor_im_chat.go create mode 100644 shortcuts/event/processor_im_chat_member.go create mode 100644 shortcuts/event/processor_im_message.go create mode 100644 shortcuts/event/processor_im_message_reaction.go create mode 100644 shortcuts/event/processor_im_message_read.go create mode 100644 shortcuts/event/processor_im_test.go create mode 100644 shortcuts/event/processor_test.go create mode 100644 shortcuts/event/registry.go create mode 100644 shortcuts/event/router.go create mode 100644 shortcuts/event/shortcuts.go create mode 100644 shortcuts/event/subscribe.go create mode 100644 shortcuts/im/builders_test.go create mode 100644 shortcuts/im/convert_lib/card.go create mode 100644 shortcuts/im/convert_lib/card_test.go create mode 100644 shortcuts/im/convert_lib/content_convert.go create mode 100644 shortcuts/im/convert_lib/content_media_misc_test.go create mode 100644 shortcuts/im/convert_lib/helpers.go create mode 100644 shortcuts/im/convert_lib/helpers_test.go create mode 100644 shortcuts/im/convert_lib/media.go create mode 100644 shortcuts/im/convert_lib/merge.go create mode 100644 shortcuts/im/convert_lib/merge_test.go create mode 100644 shortcuts/im/convert_lib/misc.go create mode 100644 shortcuts/im/convert_lib/runtime_test.go create mode 100644 shortcuts/im/convert_lib/text.go create mode 100644 shortcuts/im/convert_lib/text_test.go create mode 100644 shortcuts/im/convert_lib/thread.go create mode 100644 shortcuts/im/convert_lib/thread_test.go create mode 100644 shortcuts/im/coverage_additional_test.go create mode 100644 shortcuts/im/helpers.go create mode 100644 shortcuts/im/helpers_network_test.go create mode 100644 shortcuts/im/helpers_test.go create mode 100644 shortcuts/im/im_chat_create.go create mode 100644 shortcuts/im/im_chat_messages_list.go create mode 100644 shortcuts/im/im_chat_search.go create mode 100644 shortcuts/im/im_chat_update.go create mode 100644 shortcuts/im/im_messages_mget.go create mode 100644 shortcuts/im/im_messages_reply.go create mode 100644 shortcuts/im/im_messages_resources_download.go create mode 100644 shortcuts/im/im_messages_search.go create mode 100644 shortcuts/im/im_messages_send.go create mode 100644 shortcuts/im/im_threads_messages_list.go create mode 100644 shortcuts/im/shortcuts.go create mode 100644 shortcuts/mail/address.go create mode 100644 shortcuts/mail/draft/acceptance_test.go create mode 100644 shortcuts/mail/draft/charset.go create mode 100644 shortcuts/mail/draft/htmltext.go create mode 100644 shortcuts/mail/draft/htmltext_test.go create mode 100644 shortcuts/mail/draft/limits.go create mode 100644 shortcuts/mail/draft/limits_test.go create mode 100644 shortcuts/mail/draft/model.go create mode 100644 shortcuts/mail/draft/model_test.go create mode 100644 shortcuts/mail/draft/parse.go create mode 100644 shortcuts/mail/draft/parse_extra_test.go create mode 100644 shortcuts/mail/draft/parse_test.go create mode 100644 shortcuts/mail/draft/patch.go create mode 100644 shortcuts/mail/draft/patch_attachment_test.go create mode 100644 shortcuts/mail/draft/patch_body_test.go create mode 100644 shortcuts/mail/draft/patch_header_test.go create mode 100644 shortcuts/mail/draft/patch_recipient_test.go create mode 100644 shortcuts/mail/draft/patch_test.go create mode 100644 shortcuts/mail/draft/projection.go create mode 100644 shortcuts/mail/draft/projection_extra_test.go create mode 100644 shortcuts/mail/draft/projection_test.go create mode 100644 shortcuts/mail/draft/serialize.go create mode 100644 shortcuts/mail/draft/serialize_golden_test.go create mode 100644 shortcuts/mail/draft/serialize_test.go create mode 100644 shortcuts/mail/draft/service.go create mode 100644 shortcuts/mail/draft/testdata/alternative_append_text.golden.eml create mode 100644 shortcuts/mail/draft/testdata/alternative_draft.eml create mode 100644 shortcuts/mail/draft/testdata/alternative_set_body.golden.eml create mode 100644 shortcuts/mail/draft/testdata/calendar_draft.eml create mode 100644 shortcuts/mail/draft/testdata/custom_header_draft.eml create mode 100644 shortcuts/mail/draft/testdata/custom_header_set_subject.golden.eml create mode 100644 shortcuts/mail/draft/testdata/dirty_multipart_preamble.eml create mode 100644 shortcuts/mail/draft/testdata/forward_draft.eml create mode 100644 shortcuts/mail/draft/testdata/forward_remove_attachment.golden.eml create mode 100644 shortcuts/mail/draft/testdata/html_inline_draft.eml create mode 100644 shortcuts/mail/draft/testdata/html_inline_remove.golden.eml create mode 100644 shortcuts/mail/draft/testdata/html_inline_replace.golden.eml create mode 100644 shortcuts/mail/draft/testdata/html_inline_replace_binary.golden.eml create mode 100644 shortcuts/mail/draft/testdata/message_rfc822_draft.eml create mode 100644 shortcuts/mail/draft/testdata/multipart_signed_draft.eml create mode 100644 shortcuts/mail/draft/testdata/reply_draft.eml create mode 100644 shortcuts/mail/draft/testdata/reply_draft_subject.golden.eml create mode 100644 shortcuts/mail/draft/testdata/reply_draft_with_inline_attachment.eml create mode 100644 shortcuts/mail/emlbuilder/builder.go create mode 100644 shortcuts/mail/emlbuilder/builder_test.go create mode 100644 shortcuts/mail/filecheck/filecheck.go create mode 100644 shortcuts/mail/filecheck/filecheck_test.go create mode 100644 shortcuts/mail/helpers.go create mode 100644 shortcuts/mail/helpers_test.go create mode 100644 shortcuts/mail/limits.go create mode 100644 shortcuts/mail/mail_draft_create.go create mode 100644 shortcuts/mail/mail_draft_edit.go create mode 100644 shortcuts/mail/mail_forward.go create mode 100644 shortcuts/mail/mail_message.go create mode 100644 shortcuts/mail/mail_messages.go create mode 100644 shortcuts/mail/mail_quote.go create mode 100644 shortcuts/mail/mail_quote_test.go create mode 100644 shortcuts/mail/mail_reply.go create mode 100644 shortcuts/mail/mail_reply_all.go create mode 100644 shortcuts/mail/mail_send.go create mode 100644 shortcuts/mail/mail_shortcut_test.go create mode 100644 shortcuts/mail/mail_thread.go create mode 100644 shortcuts/mail/mail_triage.go create mode 100644 shortcuts/mail/mail_triage_test.go create mode 100644 shortcuts/mail/mail_watch.go create mode 100644 shortcuts/mail/mail_watch_test.go create mode 100644 shortcuts/mail/shortcuts.go create mode 100644 shortcuts/register.go create mode 100644 shortcuts/register_test.go create mode 100644 shortcuts/sheets/helpers.go create mode 100644 shortcuts/sheets/sheet_append.go create mode 100644 shortcuts/sheets/sheet_create.go create mode 100644 shortcuts/sheets/sheet_export.go create mode 100644 shortcuts/sheets/sheet_find.go create mode 100644 shortcuts/sheets/sheet_info.go create mode 100644 shortcuts/sheets/sheet_read.go create mode 100644 shortcuts/sheets/sheet_write.go create mode 100644 shortcuts/sheets/shortcuts.go create mode 100644 shortcuts/task/shortcuts.go create mode 100644 shortcuts/task/shortcuts_test.go create mode 100644 shortcuts/task/task_assign.go create mode 100644 shortcuts/task/task_assign_test.go create mode 100644 shortcuts/task/task_comment.go create mode 100644 shortcuts/task/task_complete.go create mode 100644 shortcuts/task/task_followers.go create mode 100644 shortcuts/task/task_followers_test.go create mode 100644 shortcuts/task/task_get_my_tasks.go create mode 100644 shortcuts/task/task_reminder.go create mode 100644 shortcuts/task/task_reopen.go create mode 100644 shortcuts/task/task_update.go create mode 100644 shortcuts/task/task_util.go create mode 100644 shortcuts/task/task_util_test.go create mode 100644 shortcuts/task/tasklist_add_task.go create mode 100644 shortcuts/task/tasklist_create.go create mode 100644 shortcuts/task/tasklist_members.go create mode 100644 shortcuts/task/tasklist_members_test.go create mode 100644 shortcuts/vc/artifact-Empty Artifacts-tok003/transcript.txt create mode 100644 shortcuts/vc/artifact-No Note Meeting-tok002/transcript.txt create mode 100644 shortcuts/vc/artifact-Test Minutes-tok001/transcript.txt create mode 100644 shortcuts/vc/shortcuts.go create mode 100644 shortcuts/vc/vc_notes.go create mode 100644 shortcuts/vc/vc_notes_test.go create mode 100644 shortcuts/vc/vc_search.go create mode 100644 shortcuts/vc/vc_search_test.go create mode 100644 shortcuts/whiteboard/shortcuts.go create mode 100644 shortcuts/whiteboard/whiteboard_update.go create mode 100644 skill-template/domains/base.md create mode 100644 skill-template/domains/calendar.md create mode 100644 skill-template/domains/doc.md create mode 100644 skill-template/domains/drive.md create mode 100644 skill-template/domains/im.md create mode 100644 skill-template/domains/mail.md create mode 100644 skill-template/domains/sheets.md create mode 100644 skill-template/domains/vc.md create mode 100644 skill-template/master-skill-template.md create mode 100644 skill-template/skill-template.md create mode 100644 skills/lark-base/SKILL.md create mode 100644 skills/lark-base/references/dashboard-block-data-config.md create mode 100644 skills/lark-base/references/examples.md create mode 100644 skills/lark-base/references/formula-field-guide.md create mode 100644 skills/lark-base/references/lark-base-advperm-disable.md create mode 100644 skills/lark-base/references/lark-base-advperm-enable.md create mode 100644 skills/lark-base/references/lark-base-base-copy.md create mode 100644 skills/lark-base/references/lark-base-base-create.md create mode 100644 skills/lark-base/references/lark-base-base-get.md create mode 100644 skills/lark-base/references/lark-base-dashboard-block-create.md create mode 100644 skills/lark-base/references/lark-base-dashboard-block-delete.md create mode 100644 skills/lark-base/references/lark-base-dashboard-block-get.md create mode 100644 skills/lark-base/references/lark-base-dashboard-block-list.md create mode 100644 skills/lark-base/references/lark-base-dashboard-block-update.md create mode 100644 skills/lark-base/references/lark-base-dashboard-block.md create mode 100644 skills/lark-base/references/lark-base-dashboard-create.md create mode 100644 skills/lark-base/references/lark-base-dashboard-delete.md create mode 100644 skills/lark-base/references/lark-base-dashboard-get.md create mode 100644 skills/lark-base/references/lark-base-dashboard-list.md create mode 100644 skills/lark-base/references/lark-base-dashboard-update.md create mode 100644 skills/lark-base/references/lark-base-dashboard.md create mode 100644 skills/lark-base/references/lark-base-data-query.md create mode 100644 skills/lark-base/references/lark-base-field-create.md create mode 100644 skills/lark-base/references/lark-base-field-delete.md create mode 100644 skills/lark-base/references/lark-base-field-get.md create mode 100644 skills/lark-base/references/lark-base-field-list.md create mode 100644 skills/lark-base/references/lark-base-field-search-options.md create mode 100644 skills/lark-base/references/lark-base-field-update.md create mode 100644 skills/lark-base/references/lark-base-field.md create mode 100644 skills/lark-base/references/lark-base-form-create.md create mode 100644 skills/lark-base/references/lark-base-form-delete.md create mode 100644 skills/lark-base/references/lark-base-form-get.md create mode 100644 skills/lark-base/references/lark-base-form-list.md create mode 100644 skills/lark-base/references/lark-base-form-questions-create.md create mode 100644 skills/lark-base/references/lark-base-form-questions-delete.md create mode 100644 skills/lark-base/references/lark-base-form-questions-list.md create mode 100644 skills/lark-base/references/lark-base-form-questions-update.md create mode 100644 skills/lark-base/references/lark-base-form-questions.md create mode 100644 skills/lark-base/references/lark-base-form-update.md create mode 100644 skills/lark-base/references/lark-base-form.md create mode 100644 skills/lark-base/references/lark-base-history.md create mode 100644 skills/lark-base/references/lark-base-record-delete.md create mode 100644 skills/lark-base/references/lark-base-record-get.md create mode 100644 skills/lark-base/references/lark-base-record-history-list.md create mode 100644 skills/lark-base/references/lark-base-record-list.md create mode 100644 skills/lark-base/references/lark-base-record-upload-attachment.md create mode 100644 skills/lark-base/references/lark-base-record-upsert.md create mode 100644 skills/lark-base/references/lark-base-record.md create mode 100644 skills/lark-base/references/lark-base-role-create.md create mode 100644 skills/lark-base/references/lark-base-role-delete.md create mode 100644 skills/lark-base/references/lark-base-role-get.md create mode 100644 skills/lark-base/references/lark-base-role-list.md create mode 100644 skills/lark-base/references/lark-base-role-update.md create mode 100644 skills/lark-base/references/lark-base-shortcut-field-properties.md create mode 100644 skills/lark-base/references/lark-base-shortcut-record-value.md create mode 100644 skills/lark-base/references/lark-base-table-create.md create mode 100644 skills/lark-base/references/lark-base-table-delete.md create mode 100644 skills/lark-base/references/lark-base-table-get.md create mode 100644 skills/lark-base/references/lark-base-table-list.md create mode 100644 skills/lark-base/references/lark-base-table-update.md create mode 100644 skills/lark-base/references/lark-base-table.md create mode 100644 skills/lark-base/references/lark-base-view-create.md create mode 100644 skills/lark-base/references/lark-base-view-delete.md create mode 100644 skills/lark-base/references/lark-base-view-get-card.md create mode 100644 skills/lark-base/references/lark-base-view-get-filter.md create mode 100644 skills/lark-base/references/lark-base-view-get-group.md create mode 100644 skills/lark-base/references/lark-base-view-get-sort.md create mode 100644 skills/lark-base/references/lark-base-view-get-timebar.md create mode 100644 skills/lark-base/references/lark-base-view-get.md create mode 100644 skills/lark-base/references/lark-base-view-list.md create mode 100644 skills/lark-base/references/lark-base-view-rename.md create mode 100644 skills/lark-base/references/lark-base-view-set-card.md create mode 100644 skills/lark-base/references/lark-base-view-set-filter.md create mode 100644 skills/lark-base/references/lark-base-view-set-group.md create mode 100644 skills/lark-base/references/lark-base-view-set-sort.md create mode 100644 skills/lark-base/references/lark-base-view-set-timebar.md create mode 100644 skills/lark-base/references/lark-base-view.md create mode 100644 skills/lark-base/references/lark-base-workflow-create.md create mode 100644 skills/lark-base/references/lark-base-workflow-disable.md create mode 100644 skills/lark-base/references/lark-base-workflow-enable.md create mode 100644 skills/lark-base/references/lark-base-workflow-get.md create mode 100644 skills/lark-base/references/lark-base-workflow-list.md create mode 100644 skills/lark-base/references/lark-base-workflow-schema.md create mode 100644 skills/lark-base/references/lark-base-workflow-update.md create mode 100644 skills/lark-base/references/lark-base-workflow.md create mode 100644 skills/lark-base/references/lark-base-workspace.md create mode 100644 skills/lark-base/references/lookup-field-guide.md create mode 100644 skills/lark-base/references/role-config.md create mode 100644 skills/lark-calendar/SKILL.md create mode 100644 skills/lark-calendar/references/lark-calendar-agenda.md create mode 100644 skills/lark-calendar/references/lark-calendar-create.md create mode 100644 skills/lark-calendar/references/lark-calendar-freebusy.md create mode 100644 skills/lark-calendar/references/lark-calendar-suggestion.md create mode 100644 skills/lark-contact/SKILL.md create mode 100644 skills/lark-contact/references/lark-contact-get-user.md create mode 100644 skills/lark-contact/references/lark-contact-search-user.md create mode 100644 skills/lark-doc/SKILL.md create mode 100644 skills/lark-doc/references/lark-doc-create.md create mode 100644 skills/lark-doc/references/lark-doc-fetch.md create mode 100644 skills/lark-doc/references/lark-doc-media-download.md create mode 100644 skills/lark-doc/references/lark-doc-media-insert.md create mode 100644 skills/lark-doc/references/lark-doc-search.md create mode 100644 skills/lark-doc/references/lark-doc-update.md create mode 100644 skills/lark-doc/references/lark-doc-whiteboard-update.md create mode 100644 skills/lark-drive/SKILL.md create mode 100644 skills/lark-drive/references/lark-drive-add-comment.md create mode 100644 skills/lark-drive/references/lark-drive-download.md create mode 100644 skills/lark-drive/references/lark-drive-upload.md create mode 100644 skills/lark-event/SKILL.md create mode 100644 skills/lark-event/references/lark-event-subscribe.md create mode 100644 skills/lark-im/SKILL.md create mode 100644 skills/lark-im/references/lark-im-chat-create.md create mode 100644 skills/lark-im/references/lark-im-chat-identity.md create mode 100644 skills/lark-im/references/lark-im-chat-messages-list.md create mode 100644 skills/lark-im/references/lark-im-chat-search.md create mode 100644 skills/lark-im/references/lark-im-chat-update.md create mode 100644 skills/lark-im/references/lark-im-messages-mget.md create mode 100644 skills/lark-im/references/lark-im-messages-reply.md create mode 100644 skills/lark-im/references/lark-im-messages-resources-download.md create mode 100644 skills/lark-im/references/lark-im-messages-search.md create mode 100644 skills/lark-im/references/lark-im-messages-send.md create mode 100644 skills/lark-im/references/lark-im-reactions.md create mode 100644 skills/lark-im/references/lark-im-threads-messages-list.md create mode 100644 skills/lark-mail/SKILL.md create mode 100644 skills/lark-mail/references/lark-mail-draft-create.md create mode 100644 skills/lark-mail/references/lark-mail-draft-edit.md create mode 100644 skills/lark-mail/references/lark-mail-forward.md create mode 100644 skills/lark-mail/references/lark-mail-message.md create mode 100644 skills/lark-mail/references/lark-mail-messages.md create mode 100644 skills/lark-mail/references/lark-mail-reply-all.md create mode 100644 skills/lark-mail/references/lark-mail-reply.md create mode 100644 skills/lark-mail/references/lark-mail-send.md create mode 100644 skills/lark-mail/references/lark-mail-thread.md create mode 100644 skills/lark-mail/references/lark-mail-triage.md create mode 100644 skills/lark-mail/references/lark-mail-watch.md create mode 100644 skills/lark-minutes/SKILL.md create mode 100644 skills/lark-openapi-explorer/SKILL.md create mode 100644 skills/lark-shared/SKILL.md create mode 100644 skills/lark-sheets/SKILL.md create mode 100644 skills/lark-sheets/references/lark-sheets-append.md create mode 100644 skills/lark-sheets/references/lark-sheets-create.md create mode 100644 skills/lark-sheets/references/lark-sheets-export.md create mode 100644 skills/lark-sheets/references/lark-sheets-find.md create mode 100644 skills/lark-sheets/references/lark-sheets-info.md create mode 100644 skills/lark-sheets/references/lark-sheets-read.md create mode 100644 skills/lark-sheets/references/lark-sheets-write.md create mode 100644 skills/lark-skill-maker/SKILL.md create mode 100644 skills/lark-task/SKILL.md create mode 100644 skills/lark-task/references/lark-task-assign.md create mode 100644 skills/lark-task/references/lark-task-comment.md create mode 100644 skills/lark-task/references/lark-task-complete.md create mode 100644 skills/lark-task/references/lark-task-create.md create mode 100644 skills/lark-task/references/lark-task-followers.md create mode 100644 skills/lark-task/references/lark-task-get-my-tasks.md create mode 100644 skills/lark-task/references/lark-task-reminder.md create mode 100644 skills/lark-task/references/lark-task-reopen.md create mode 100644 skills/lark-task/references/lark-task-tasklist-create.md create mode 100644 skills/lark-task/references/lark-task-tasklist-members.md create mode 100644 skills/lark-task/references/lark-task-tasklist-task-add.md create mode 100644 skills/lark-task/references/lark-task-update.md create mode 100644 skills/lark-vc/SKILL.md create mode 100644 skills/lark-vc/references/lark-vc-notes.md create mode 100644 skills/lark-vc/references/lark-vc-search.md create mode 100644 skills/lark-whiteboard/SKILL.md create mode 100644 skills/lark-whiteboard/references/connectors.md create mode 100644 skills/lark-whiteboard/references/content.md create mode 100644 skills/lark-whiteboard/references/layout.md create mode 100644 skills/lark-whiteboard/references/schema.md create mode 100644 skills/lark-whiteboard/references/style.md create mode 100644 skills/lark-whiteboard/references/typography.md create mode 100644 skills/lark-whiteboard/scenes/architecture.md create mode 100644 skills/lark-whiteboard/scenes/bar-chart.md create mode 100644 skills/lark-whiteboard/scenes/comparison.md create mode 100644 skills/lark-whiteboard/scenes/fishbone.md create mode 100644 skills/lark-whiteboard/scenes/flywheel.md create mode 100644 skills/lark-whiteboard/scenes/funnel.md create mode 100644 skills/lark-whiteboard/scenes/line-chart.md create mode 100644 skills/lark-whiteboard/scenes/mermaid.md create mode 100644 skills/lark-whiteboard/scenes/milestone.md create mode 100644 skills/lark-whiteboard/scenes/organization.md create mode 100644 skills/lark-whiteboard/scenes/pyramid.md create mode 100644 skills/lark-whiteboard/scenes/treemap.md create mode 100644 skills/lark-wiki/SKILL.md create mode 100644 skills/lark-workflow-meeting-summary/SKILL.md create mode 100644 skills/lark-workflow-standup-report/SKILL.md diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..5aba7b0d --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,36 @@ +name: Coverage + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + codecov: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + - name: Fetch meta data + run: python3 scripts/fetch_meta.py + + - name: Run tests with coverage + run: go test -coverprofile=coverage.txt -covermode=atomic ./... + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5 + with: + files: coverage.txt + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..2d0da6b6 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,72 @@ +name: Lint + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + staticcheck: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + - name: Fetch meta_data.json + run: python3 scripts/fetch_meta.py + + - name: Run staticcheck + uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1 + with: + install-go: false + + golangci-lint: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + - name: Fetch meta_data.json + run: python3 scripts/fetch_meta.py + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6 + with: + version: latest + + vet: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + - name: Fetch meta_data.json + run: python3 scripts/fetch_meta.py + + - name: Run go vet + run: go vet ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..94d6b56a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + goreleaser: + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6 + with: + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..11136dcf --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + unit-test: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: '1.23' + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.x' + + - name: Fetch meta data + run: python3 scripts/fetch_meta.py + + - name: Run tests + run: go test -v -race -count=1 -timeout=30s ./cmd/... ./internal/... ./shortcuts/... diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ec525ba8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Build output +/lark-cli +.cache/ +dist/ +bin/ + +# Node +node_modules/ + + +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Go +docs/ref +docs/ +vendor/ + + +#test +test_scripts/ +tests/mail/reports/ + +/log/ diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 00000000..4040cc17 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,40 @@ +version: 2 + +before: + hooks: + - python3 scripts/fetch_meta.py + +builds: + - binary: lark-cli + env: + - CGO_ENABLED=0 + ldflags: + - -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }} + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + +archives: + - name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}" + format_overrides: + - goos: windows + format: zip + files: + - README.md + - LICENSE + - CHANGELOG.md + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^chore:' diff --git a/ByteDance_Corporate_Contributor_License_Agreement_v1.1.pdf b/ByteDance_Corporate_Contributor_License_Agreement_v1.1.pdf new file mode 100644 index 0000000000000000000000000000000000000000..043c0aa0fa6c52a9eaba833467513ce47c5ca20a GIT binary patch literal 127334 zcmeEv2Ut`|vnZfQRx&8TAt!;EAtO2GoO2p-&cQ^IAUR1?a#p~AfTCo{pyV6`kthlx z3dlRdFe|(6zrT9#ec%1w`*)d{b53_xRaaM6SJmk*8Pud?*dZJ|*bFZ|=YGS6fWROZ z3wvxKArPmMxw8$3+2593QiTP?sci11KjW+ZZv^rmY!PfFqnpmizkQ|@l?yt6$avz1b{fY z*nl{7>|j1HcQtpIHOw96YzegHJE|ulf(>)Ff|CdZ?vbR5dwJTrxPyeOVb<0>7CZuA zFci$o1-0hk;)Vg_NxH+#J?&harOZ8HAZ95+C>RQbK;VDC6_^do&jbcD0ew8G2f$$# zmypoc2gv|k(14j+!Q4Rrmp~96po${Q&&S2x$^(H5j_>3L3IVVcUHnj4arJPFALA3q0m!S0&yZn z<&WXzRDwC%c-n%v02+aT$M*09IO~KhBjp3$n8krq$(9 z1}nQt!ffs2{M})i{wi9Q{tlJ`R%? z5D?%5Lph;PcAy5kho7^jxi7o32OScE6wJfY-Od#*7RQiWPe5|^;5Z_M!_vix6DZ(> zaBuTMS z>XnrxC(IG%1atQE04Rd|NRgGLpe0<`UED>E_@oJQLtHr8i7ehr1HFx^Sd5NA5CJ?w zTpU&6JlXW8i3A4l4=Vq}GhcI8fI{$(6ap;-kLQ_|i;LsGX4_vY3jOf_1{Z!2_#|eB zK-i%WEeI5t(!d`ulLf(GAC^o z5D>UucvN4_9Pk{E{7ew!*f0J5NCoEW3HWk=Ps*v`;s{eVcLhO?omB+ofALbkBZT{* zNQ6>$9e z2LkVr+Xx>YxNCSU67aD^vfyNY5n~KRjPd`*{z4IQ_m}=c|6PBNr1oz*3`K|~6d{QJ zD?5BF{(l}|u7BI%lOdxMBMe1|ITRt-|0_F;m~4L;VL%!EvqT>$7~}}cOM#dJq@gnW zP-z|kb{-xs9(HaiJ_tL%Bt(*3l8=jzms^ULpI-)g6#@?)0n-_Zm`6~=1mHqU04{{E z0aF~VySNZS!i5+RE(9aF5RBwP5Y2^v!i9jsjex?9fWnP{!i|8!jex?9fWnP{!i|8! zjex?9fWm`-!h?XqgMh+=fWm`-!h?XqgMh+=fWm`-!h?Xqi-5w5fWnJ_0z`}89pgnn z;YC2-LqGvUN(~u2gzLa#9~v0GBRzx=V`(5- zazYy&tpmuhQ}J?wt6ngO4?zzE#D|~=3gSEFGKlY(Lm)l`UB)MY!RbH(J5ujQK}J9q z!Yn;OoZ{}DcGl+bE5yB|t-1T3Z#-;aK*)>}(51k)l%2VaySWpHQ^wB5%N+=X0uO*l z=%4CHJ6i#Z2vDYS^eqE~Pk{*EF?{&l$x=B)di*3s@m7fEDPtwyul2gN2KW!%0rVpPdBoUF{z&a)0pVXrTheNLxc5IXvQ) zN0GOqN6b#`iHMfL!b?dVaAKv6wzlV06Q>~#M zepeUwuldi{Qtx*?81xZh>b2y<&Ajw+O32lFak-;HRA6-=vFltB(~{_(%ElGFJ9ioq z?=;^Z?Gj&Tc}@`!zcrU4yw$ZgsJ}i|*^6BPz4obVx#ML7*Fn#h&Cdggx{i;oXWpT_ z9I())@8F%O)ysp^gz{p4P4=1K$|uWrl%DS>>7TC_kqW*r%ezU|tU~tG&h~DvYD_xE zn#yheovEvQ3}X*_%bw`QcznBNSS~I#V^aUz``H#>tN)@>=chH(_eC>IA@OfyIW9fs z47W>qD*k$+WwDw24)@|G?o8qVeSZBuMVpm?M7EkyrMwR3TX)g-Y!#Lwh3gxa(x|z~GR(^Q0DH*Za60m0%{`vhvI{9V`VRcP*l+Ne7?$c{j)qNrN=Xwf_ z8h9=muSXhBH$6fJ5s&DXn<@ATwX#n(^k7uwC^DY+eU&5|*xB%uxg+)b!w(z&o68!7 z#`gvY#NIZ$B#hb5VE4c29-{j=`^9-PFlXY_B9GD0wdF4_JN(^5c3Z^JE{q6$Q0jd)h9~0Io~gu^aC6BfwaMhtfOgNd z&c|=N7#hePdui)RYX(&}n~EsUz8#CNu6Xf4$c<>G8d`Ai)x{cXXPGn3ZT;I=xedzh z6Rf==%I0_+aj145F4U9T+4DACWAATYOTtV4Y6iP$;&`VnKG`bRlBs>{UO(^DGY)Sv z>@o(&-JE(a&$Fqj?I4D7e`78We~jVmVyf&hope=LVSG?eF!!t%57R*X-LF^|T^VNR z-k>oGP%AGo>7(5&e7d(+`h`~4ueBkv_ePKSnB1sbi2?Q!sp+$1%PL{%3p|Z9G^KGf zw2Tk|P{FovI<_sj(jEC(`)_StG`e|yB2OMohCHL(^e4}Io`Vyt#ksjvlI5Iwv4S+j zJDxvvuEw$0^P@X@Wn+;-=EWjLUcS7lcyw4*N0XF!c>1@2%?oGqd8(8S+}uCiBv@G7 zJi~L!rxD6DVuLM&t!T7$o4z@GJy%?SLH7FlNt6i%+}9ywI70VyHZM&R_Hs~&fA&UA z53dzK*}nD64po!2s-;ajm6=>O+h`cKpxCv{54HF3(yM3J-Jcr1Q(&kuQ`&eoK&oz4 zm(I8lw-k@rl(^tc@7NZf@i{G&{*r^y$9hEx9)1J&8xb&6*6y;Mf#(vWRvMx^7Q)=P z5u^k`O>g{_S*{N%cnl^)+>|?0D*=5TOmYMD`IL?bW~$QjYq2QB4i{ugO?v%}_BB`A zm%nLj$ex?La^2lH+kJqT<$M8py$GRM(>*!#pSW zJs8$&!bte$OTtG#WuFhDCCx@__dhkMl|mj?r_QtR!6)ul7u;(e#Wq@QpPr}2p*z>UcRKIwRYKAfhJ!0f@-$a8uTdBTM?X3f zIj=~QShpZta&Y$}{U~|tQ$lokQQE~jEJR}N#`A2_!rZdW{H~TWGPQYIO9#sqBRK2> z^_$O_x1No@NG(`<&kq~X-KH`%?nYCn8u+}}dWpEN@&(;Dj{UROO{s53^7%@~lhxEF ztLYYBp}89MvAx`bCa(b-Tt%Ippx#Y+PjJish5(?hQB^#v?xlU)vp=G7h z`k3^+Pkn0j5Spx+aA9NYLzHL!55weg45GhKEV4L!yEmQ`_^$FZXS(v4UbIlMdraI+ zGvo_-3e)ovaS`Zfim{v~w`u}Yckv=-U}op;)pj%nj5D|JX3U;->BwuqbHWvDhzrZR z@G+I;$))vsI0JgIowT)q#D`tvcIJy_pADdSH?Zk;T~TU8)k5R~#@4kY2^CY*r>kGqX!`ITMqMp*TUpa- zn>cJaaLV`66nC!B;rGP7gW;TV@!`tYrlP_mNW@xxtIiDi@rhkILy+&Xmd@#_8-25v z=s(Dhw!CRzl+)x>a*ETfJ@m;J0xv6nDGyciVUKVO9o@QIVjYrdmKtqv^R!(FuFC<) ze!*(|wmYT2mi>H;1mSdy{Nkf_`?Y7`t+fi>i_<<2?Nr%FQ11_mjgZO-(++QM^^O?}e%=vHV# z7|3;!V*!k3#JRz~HZ)s%?%j5k3ZHLM){?JS19WO4h*0buAs$ArDAjP-aG^XV&F#?Q zFf+f&PJ40&=cEj_G8i7qwvysY&5y)+Wy~5M21g@h+gv2WT9se{wb5t7z&4YPe&k^3 z(UBX6ZO;mIiqgw8Pm!-}I@YWyLb+)p&}woXrIfaztCtrBpBs<}x zN3itRvf24@r$GOBFYVaJcBM)SY`eR`hY47?-U*>?f>}9N%Vtz?7L{5}{WBCEChIIa z=6&h-rV%)te>ax-995iE!SX*3yzO$3?FCoSFhgE1ZJ4?BqGO z=$H0^mothgd_K?%6I^5(yHHRhd@@1!Z$TrUdEBl9>kV8 zjAT5cAHtWJAcl{edcyEM@>!!n)~RV%!npXKC%k*9+iPWtmi(Z0P>rF`G=~J;$Rxic zb$+l6@7_&YqPHLF&eVf7oGwRa%fZH;mkN!I=-hr_RABF}u=i+>ap;DRQ&?`27n^Lw zRE|f2cR(lJI^`qMC2A(r(jBU}SKR*iHTo}P^DH6f28N6TT#`u~ScPsINnmk`vkugj zP^gQ%l_#XUuft>%S+A+cBwseWo^?P~mu5=O8@*tqk{5hyPg0+8_lC-0RgzTdLY!MQ z{x&`X#QADhpXNy4SA}^6J2hiyuhv#-;d#NO8CH<(izfkLqabU3j>@(myI`FT!|-znSN%)OzV{JaKDFNSQAyOX`(|yv zgbyzY@4n&u+AUbFQE%EvaC_=4!?#;%(Z!FkjYLuP-`kL5DRsZ<*rQZGdn%M{&n9M0 z?G{M%mFWD`g$Hg?UwKRYS|=_X>h8RwiH&>GPVXFim-n3eMU_;8aF1~7i%(G2WvMeW zd<`BN65@sVhUUUXT|K2Pzn8#Y#C<*!o-$CueziVV$}n3V}qA zdfiNWN=ro)^xm0wjh1Bl}_4WA8*+u+53NFt2JAz&dX*ZmO0`kSo3GI7$y&k{} z=b$WAv|f{IS@%^;9~C7p3{l-nb}Z6SC2}|?M{IXfRy5gdXI175eeo6lLz~7vis}^O z_n5={-CdG=SYMgOx4FDic}Bh|W)Vi%Ik}RWaPyTOScOZyJ%1||nur@e{6uhArvA(NE{cofUi- z%SQbzR`~6*z`9!aCt@_T`~lNz;n|*)yz91?{0b*Zn6<;lWnT5?5YK&lylpYVwx~}` zJzgPgJI(La+NgUWu{fx=1oRM1#azhWQBW`B&d7CC=Y~M};O+k4(vXp%%Y+Q+HNM$Hv0ZtCmPfu!WYVZi zZm&HxFH;CG2R%CXNNHlSKY?vPz5d0urA)3&flL}04$ zwzq4fB2#qdv-Jk-<@#y;d z_rg9q{n4+7*Z51)SNqRh*?&`T7<>25r(KVCYZRV4mVKWRs09x}GI-Z0z3bYQgT5_4 z+tnXHf9u&%e%)tAS>CO}<6{@wOMMMbq&rY7|P>)kQaOI;2&8kf$A_7u$&g;<<& zCiPi1CoFJYFbrTWwSWlYGJZ0UduhcZ9L1>U%R+Yc%GJxlxJ-Wf$%?)nc)DUyjM{#p z(TtxCxHF&K=-XX9;LawXo`vD{aYnLWOHq0e;rn9X4O7flmb> zI_De?lO_qX zHrwh<_({#x^%}a#+pqF{j?pNJXOo|Zjb3h*q95SP?;T7u>bHL>|813f5cf-G{<$yI zM11xb0=rDmhw1**SFd(FM#H!$Z*|aucZo2LrG}w^kn>#k3JP^vr)srGZVTAsfuq%0 z%pDzyL2JzWI<}gd0#I8A@r(5Oc`d~xRVA_slbS9vy1WG`HV>>mFY-)AueLjoD`WE$ zkj*#`Z&sJjvAxLQdvd{d#Ab9S_-pFSWsD*PkUdjTa&!x0p9j$`<%$camNIqE)NoJ9 z@OU?cI$%gv^x_clEgNQu-{J^ciC$5A!|g3hK}?A$gH0nwNR^$x&s*ih^a4kE6(wZt zNlAhz{v(Q{($d#KhPDH^v@JcTEG<-6$w^lDy>T~7sGdDX#cN!;^*G^i^@47wFDvO* zZd=Y~B1+PyJnv@P$9C7&13M2%Ewsl4Ou5UdqR0dfFBhP0bCU~c#STZ_u1vRx&Wob^ zRwd>a-_ya-#q}rz=LPZ0t2iLTPb9@=^PC15ykRffx#%s`nP=NlmmJlu&ehJ^#lB~i zc_Gd)^13lC^}55Q(+|#mQQ5fHh(Y8EUYLRHu0{$Qz%F>XB;G4WbtWpRFcS;0L`%WBn-|B1QFb%n5@&pk zL-XPgo4dyA^YZrv@lb3ebV-WQRoC%b8c^~jKoZrWny%(8pKCZX8=wJSvY-ypWEUff z#NVEwk})gT-!(O>nD5v}eSV7HTM>i;k=RZ0v%>DrZf3vGUpkD>iAhT5bhb4BiQwd;V zn&lhG1R4@{U)LSXy$ZUnsq#~r-OvbW(k7fK4zBrl*FrsUZsbCnb~eW_t5`qdeJ3SS zGqJ}C8d|~r?wo1gCK?kR`Cu?>>`MJ`p*Zw@ zTfqkY=x1M=b@FiLR0WpzarWqF4-5$Uz+hIa5Yr)wjbzV37sEcZ{Pz!-`2GEf1aWej zdA{n2;%8pXah?_{4)xKpjpAcK_uy>X=2TyKn$vOF=H|Z9m9$H`U5$;4mx53VJmxtQ zo9BuYr;1&8ts=W6K%)4jS0s4?a<(si)G2EmK|jB;{hm&e5-sI@nzMKCf)$jM@0#N+=+=gu?Z+*!%ESXmQ0Wp1@Lor+L(#b8{1+!-1JxmUApt^=%2#ieJ? zN54wF;CJ7J%PYe(urEnSm8WY;J@TTNs^kloq?Va zfd7f`V<@GHO>mxF8dn@6mWN=L_`Sg0>b&K^R+>x(vmpO=XX|?A$Af4a`Z!Na)ygkb z=!spB1E){>k-v17=NF3h$GcT^$}x=U6XfI5oIM6<3aZMfTRtz>Q6j6wc4P`!lO4ES z1BkZO-K}hE6>acWK7*ZHH|b&Y-60!y{*DgT9%+L#uB0|AX%Pu) zNXn2bjJMJ$s5C1=d!4%;weFHV#Qb@PcAYEsM<2UWxjfJZAa2Fau@seI@+*bBSuB?i z@Rod%RD0q{Z=Npw%B873pF#e zEJ`V8xczURokU?(9exWt)vr-~Mn8XJBiFWxtiG@ZZv#gvGC=W=)7Q+kjdB&zE`==}! zR<*4zMFtf6zIseJOzo|+|50;?W} zvzA0gnW+!V*>#O6DEiXM8=Qz7_0#Nb7>1Z=Z<(n%HHpq#BU90S^i(Yxn&U&ZhZUCAqIXyIh#mo})nnn#j&B*ldmdQ1lO({6X286ECjYI6{IwCGju zb(O6Zi*()VzEC1A?NnT9-Eu5f9oFnkuP(i_CQ5h%Ej?dH`3zF$TC*@SaIa0udv%fu zao?0;Bp;6j>&Xq4eZ&JRMwsg#@_J$3v7Ash&Aa7E603KSKg#=oO2a;m7Z;y8>v=mz zy8s{9U09G-uX3WeuhwhxK`f8tPHNR98!oysgU1z>iMC-^W=5W?!PESBQ>tlJstkXvQly|D=vvB`4nfIu9pl;Q zR|3#9d;0`tL1J=Qg&=PCs~MT>!mE`V4`lh?`pr>2xNe+ftLqfyGOJb_LN%}?%n|sA zkJl}9$hc)77muH{9W-@0Jovs~N;}lT4-Yy>L>AT3!0%z15jd+|^xAzqMte)Px{bJI zze-~#kfPu!&J+#oVodsjb4~qOW6@t|=;UNOzj1-bE7$7eI_$kZhbC=ZpbAN{p+<$> z(-9tWG4yIEQ>eW6(7YmVNfM*X>Nc2~LI{IAq+}I#RmzGa*V{|^Gs^j@S?>!?pP8%1 z-zc8yzNFUW9hL8SZRO^Ucge+!MO5t5qQslQB(f=_I(&|Im6zA{9QIH5H?7c2dE?S# zkn5N|&#vVtdL7kid!UzGQ*thewg;~Zr~2s#yU|DtTio1rZVx&A!ZTJ=?~F%6jYj1t zx32I#7Vs`Zvq9t5t+^=Z?cN@2krnGu+jf>PvXTRG&(Y=jGZC8CD!cmXX^ImnIiDh!t{=;AXwbA;xa%*iDyHD>6(STj!8ym?r#` z?bqZ+`EY^h4#mXj_fXWH4xJQ5X!0}tr#Xv+bmUhadeA}?6{GVy`%jZHTzxt%e*2A^ zmp~Lv3-<`gp_egl#={iW_CUH+mZ8DoI%Rj;mjU{pA^ly|PC5zhMJ7p3I#nbtQsd&r zm*3blKY2$VSS~`=GnT3I6w;UT&LVfkI?$zCWaUg#`7Ox9m22SOjhF1}q?upcZtXfn z(P3TO5=>rgmm%UH(mH=7v-HCo>bL1{Ug}UOotpE0eQ56*O0v)yBx3JHnmU2!gU6ch z%(!Lp<~dAKTHhk&dTFQhWM25@UCPTyRAHLGT-a z*QgFSTc?&5KkQFk8N92w`Jnrg6ScTKCYjsW#6!=WWg~Vn4l31i!q-0%3?C4P+_}?y z^FtsJZVu(MxwSV*>dUwr z@;vaqH?PW|>R?Q~u95$-x~ffEzJy9g{|Ot#`dw1;+jw@fZM+oek=fpPaZJfos?+iJ zL?5YCn_o{?$vx|YIXCFn|75@J(zsstr6{4Rpok^X$KA7+h4B4owRGPf5QATiEt8LB zHmpu8&VDKceasFS=b|E<-R_a4d9%H%@seb3?| z*`^?}HJ4bo*F3i0LWQn^p7#EX`o(+RV%r_X;nW7im(P0APY@jJ7dC>E23`R!MWx8& z3sbh7_aOAQ-VDZ@+C0?25Yc0(+VfHNo!qRAnDebo)Mba+mY4gf`Z^lEUKe|O`67A< zGuCP>M!Arvw`RswRc{H)8+|Gr3tp(p+aaMl7f`2G))+vlz z{0=kIZck6Y3Vq?nHdlw*P3SPR>TX^TYS=^DAb&$U$DZ77_nrZ+-h*L%-rUw~6?5ZgX`sW zkY(bv1TWRcjft;lho?f~w-|dv?vg!qSsXKj)m%wr>uT%^^U6%+jmZ^V;w!oI(408J z;N?0!>CL;h(&-xBzgvTLp}pDU34PejYx-0pkrbySl78EDljt;RCg`311%`7R7m^h~9J%diq1K=sU$`@Z*1@W;tw};mEvzVF^~#9$HtdSSd;N9~aIlEiJ_~CsJ7`2%Kd7yxPZg zCZpl?+XtI-tX@QvZ_bNyZFNTpKOHAqmLw)y7%CgnR+sv~svp<)aMelOam3X6^Gjps z9y$AfvR3{?&)No<)9l^IdbQk6g=ymI1jR=up$8>+hd~WCEsQ4b)HmW9VBDVVBIjej z?WZ}m<*n|0u-##!qp7`49&1*pypVWh%q`+&vCY$kYwSzU>bE+ex4RM;M9>!pCO*8| z3ehudKX7^TilTP>{hL=F1y2WYRPyuG*d10}F+}w?uV3*WUhQifvzuMRlu4Tj++wR` zy*^dJAN%#0Atg~C;meHqD^CM_$mm#>68J7UW2Q~5=i-ID3(N3%(sHLhmRAuf&pRNx zQQj4#=6vJkdpkD+fv(AQC#mFVn#~wt;9LJxlmVNGi29yY=>BAjV-g*$o*TP^>Yqt-_&A@gR3U zbQjMX!kx-ru>DWd9u&R)Y_+;c*u)hk>L8Fwy3jViqC9CA*uY|VF^T2LxUE5P+PSvI zAwpQrns}{b@mq2=oLe6{I#V}fB%Fz-?M519+bxpc*B57=pL)e_-}6BsF=%4e!^7#7 zQQhlrOt-4sbx)li{=njLeuqvX-C4}RRRk*&Re`Ny4ORZ)Tj4_c+tH~jhAyOn+?bcQ zoiSV!?IuyPJ&iI1jahl}gemI!Hc9MxcP*LN z28><$Gj~?7HE#_a$b{LmD~W785yQUD;1-`KmpCEe01|$%Ek(rXz%bL2_uA{MiUaSB zSJ5w8-t`tKe4WW5eEh9y{)1l!{2tMCf6>~)N4`D8E#oVfLGr<}9D$!9d6w?}#-qdqF^RMS^F63A>XQx&eOT zM^f!ZkA9jQJyh%WWu@iey!W#2p-EL4eh5!Ui1spg0itckePzOu-Vk(?WaR^8y8T&G zbpj__#tsY|KBjyX3PbJ8jJ9lX5%rYwZ#AV^2MhML*iorpWeeJPL&8+T{Iu^*JJoP-w8N5FJ)rhX9*^yf9ffFd9G-p{)J3%tW=zUY$F?c{Fo;Y}q+W{MNAGcnWc{t}k2i;I z;am`UwF#3?Vmcj5tl;A>|2*jMYUulWqVt(dZgT-kBA--vGyMj4o`MCNy)E=6ATm@h z3Pzl?%ocpkuZ^XCHbL#!?ymFpIMewpoB3&=?1A@!1w$IC{zpPKB@>eLgXSsA(QT*N zIQ5zL67O)k(3=fZ+WWjGzwLr5N(b}pV7$*McXO~Re1hHOR-WP1aN%A!>!7xw@tHQ# zta;k0=2CVeED|qZbZLB_MWfY_#3RU%S}1(HBfOL!5__qXF4KSjPkKv*j|)17uN@!B z)W->$pY9_3(&MkEsv5V zuZUY*{LazW;%m}0E>?I-ja3h;JRCxOTUXa6_Hg4<$LKh>NHh+fBTuJf*L23|xuy=Q zmpf1|nX3M?TGMml&0Jb9+fUuauDFVMLxP}$pB;zExUEptDz!` zZKRxI)T7PN0-pAYMLF(qyIKi;)o5GuK9)vxZcoK6kGtIz$AQ|&dCV&9=FBX8Vvdg#_ zem&h6(Y06iTgIQR8c%2wv&5ZytdOgv_6dK{tN~x~1`B`kxamw9j~k{nQBc*OJ9s)a z?ep87wNdZB6hFb3FyKtMTi-_^Y7s^{_xIipg!-({IUjzeb$dTAFBrSUo$<83NIXXK zwtfmz*MR10f%ge_b-MDXzcqo)TIiq&TCRj&qhvBK5tV&W%3&2odNqj>wu>${D2%1p!<*<2MF{fY0(>gudcGxnZZzW&&NY<4|ksPxu`=K1Fm zBiRf(894V3^>#&L?9)bGrMWD3ljL0jzq^8F(z@(6M~@eBKBmOL)q;X!v-=fQIsF;~ z8K1GlS37MVlJ?OE

C{Eu7Nm3%ZB{Jjxe?*Td_n(mR-=}A@GWB-u3Yr#dfckM+d{=eLyo6h z78235w6^^Cl;^c2z%DCZ)1KME2~ISo@nN{MP|PLppqNvExJ!)9yZu_~?Iao2B!e4$ z(kwW2yPq^{{S|Z9-*_sB6-w-@+r^bvK1d)Lg86354L>XH4)V)f6>nS^y|9-#pq9!# zJcQ=f2P!UFO7HA2GZv}jS7sEfwzC}jOpaIDZL%0jO_k|}`i^l^*nraIQEQ2qy6So> z4)|_#NE0O&fyRBz+ghb~?d;#08m@DiT7OOWYRfLn%C_bL$x_q3%hpkj(V5*MUOts= znBY|0CeR>tQL~4yxcWAyC5jPdDXk=muH7q!?GKbm#>*Et>kgDR6D)iNYzj-AmEGeg zPYE=Ygw)a$8wqEBQVYc>{@5%)qCiuCr$+Gnwx(VCvdQyq)YGmD%BV(KgQYm68l2!8 zk7Myjp({A2MwQEFq6>#u%Q030`YV*PZ~D7-sg9rBc~7Nm#52)S`(jntD>h8ApPBQ# zv@dDwC#R3Y%?+9^4UcP+GOj=0)n$ymblPTCY@=lFzCUKhy)YDN(MFnQxz>JPXkcsE z@B6GpqZppKieU(a=sh%TyOwWTW6F4l_9PBP`A1!Ma>MlhfUbi(5=f2lzgO2GP6zy1 z*PVQ6{`2QVz{lrA!0=bwYRKbkoN6}5hvVQUyySol?Ei4e;pl7$l4n1U8G4Kv@(-BR z{t@xfX_j9{%yo?TXrunW=<>fo4BvI|>xhpMx&ZqO1ddOeAa)H1Aht04Yxe&eTHsq9 zew`M9Vri2ejoMkDKZFBlL2R%e^LbQ~di0eROvE*V)bWhZg%miTyn4-_v4TNGLh7*}=k(8`{yjy;g;ZoePU@dW{d<~>3#rL|oYg;%`u9{B7gCk|IIVvg^--RI z-p>ZRH97vG_KUMqmJ{ zegid9m;a4I{b|(jiobyxsmuRHq5d@L-_vS5NL~In3iYQ^|DICgLF)3qQ>Z_U`}dR@ zFH)fjz}x>tz=1e1i}8M+ta-T?YSQa;T0D{UL`+LK&Wu$lb}@5vf?ezpCN1GzSvyd_RYGbVBg& z8cK@86Z0V6YkY5hqTxv`Az|(TlYt*F)Dw*gE*y~fkZuaK<;`@ zT{~xSXAisY4`l4zJv`xwm_UFSbHdvr^KJuK-+}DvM?3)%-=C4eAy%>zU6Qu~G6vdt`mxKMBnZfO1qN8>qU~&Vl%xm(My!3u+&m)i1VJbD z&OZ|LopZm+N`z|q&PpKFJ|H3pUUHoz{hxV>n8_zdN2)CNVb6a_I=h66qZOQ?zfV6x zlboa zIUroTN14|E{q{XQBb*<9Itc6K=;#Vh#Rvf-X8O^)$rFUj0MivY+mH>BCt!gstpE1t z@PH~?PX~*flB%|A2L=jd{%Or0UJdh~G$H?=?<`WyA4%r_>m!R?Wd48X?2#FST(Exr z#S_vSIF4uitsw#&`2-fMe|VLD^e+0>aU-qMys-cJZa{iK>XRn)sk?r`XkNl-zI%LvRsfn1o94gnL8e(6krAc@5p|> zUpz?n60on2=sC_q2{$cInB4ziW*x^Uk5g6t*{=QfxKA!WsuuRhWW*=H zAcRp>LP6t%&30roB3bzJG;<@}Qe;dQ8FTqF%l`Sbn7~QJ`t!JXkX{lpsEZtR77H~=E}$uZ?T&{p2rgn1A$Ql+Dgyx7EqHbd{707p&Xf^dPm*+a*Mm(#b#=tNxn7Feb5G``W-PJq4 zjA`uRT%eSTPLTFjDP2?8y4zLWw-|!`F;ER+Es51=lM7qpo?>|*L*3U)$rK|d9_CkK z&e#&VMuid7hct4dIp(Q~xxbYa-?9?PQO~UJL#P*I3gZG> ze0|1Ps;Z0f8|CZ*J4j9x#v^(Bb^H%6&7IjffQX}EjuB)fSYtebm7TVkKG@*=mJ{@;7M2iMs#_Ah2*Fl3I=i+!?9cpdmP{1*!38|DF~)6ab1b>Sa44EX;7z{xN^z{}zV5-P(#N7sPT z;{mcY!>{2V1WsfLKfk~qCGc9{qjvBsUZ5Sk76A1q{(-ngv_^cxDFg#En)`^>lfUqC zB>hMl;rH-szQ5@oygnB|3A`NH4vr6b4TpVVa-Uqm{$8?x>^&zX3y9_XoMZuctdAuN zpq3YC_D_=ahl_bay!e22e^rEkFJ3$VE~Iz?a`$WEh4iYB;)N7|zy2bG4musGMc_zuj!WQJ;5AMR z{YgXMr`*XSvf<&FqXq6?JInG;HeA4}2cNfas|Wt~J=*h6jxvyc{ogvuYzxrG+^@L! zw81D6I7E`dm}~;K6=%5krt}&a?y|+bo`A9{XNrz8yO3Q9NSuuW4j0V#}1FW zcVxOAzajaHJ)#9zDF5J#ARr`g0;F^BR}z4*)<2Q}AI)DUfd}~> z@W_}%hRl!slV2s__uL{LzG;hk$v3RQxAxp7k^g zJ?DU&)rt&N;Z(7+Q9mT3DXX3?|a(CB9X<#)MDo5Y-jT7ORB6jz6nm+ z-~!3;g7-Ji4<-`E`l^^qM%Q9!4QJfOAaVicI9^-MW65S1>dvt@GaWkLxX?{?&PvaIOd|8NIJi1imIsA2APa^B8GUiA+*r^~tu}zCl0OtAb zOB5z9W-}kfS}Oj>;M-$NE}SpEH215%Ab2($BKb&+`TDjYlLYRP`0go5QQji&q!q3d zsLN~R+czbC*o-B7vm2&^_=2V zy@X?0xH*A(uskL~Jk*J5H?QPYT=}VtWyN|yC%m*2*PBh+@*D)G#qt01_us^UZw{_j zuF~;1nTA#$59>G%IramrmS&RqTp;S-OkcZIC*C}C*9O>akb_CaA)$mBd%9sDoE-mb zF>lXN?pnVp4$ zl~SY}B+ALhWSCaa-c6n^Lhn>5B+dYT~!FN6mwG5e00ESzeH#{P~by$8z!?apBo z0V~uJ5W5vY27Qb&c_wS57VJTwnf$jzRSN2U(n60Cfw_*76=s&*@h^Lo-*{cN0~#Us z8ZF|}TgtS?ltWfe@8F9QwF+)G6?Nq>xK{}(vU(F2y%n2eJY_MTntA>#N1_fH!^$av zNP}*9Qln1sc~XU?3z?;)u(zFgG`w^CC1Wr0N$1(9OT27Kr4{1kdVC0NT8m04hqMay zvbUHDC1TaF13T5%m=n4Tx%bx7MlEF9Z%H<#w+s%36yn?4zn&e;dJEpxOw`N}AZ8Nc zQkovbYtrzEH0#!93#)?4w9#1z(WeB(+r1^P{D6L060-y*=~o{3i7khuyeE4|h5&{m z>%cWaN^g#X9cWkVIA?P7Pf*9qJ+tUq;#~GXP19@fGTQFY@yV`d?&DA_prNEAD;F*?6fV*xvq}sS zHzhJBVYgkJ!xYYWjUGWqyRfby6{)p?9||S1km?+3uQX{OXVD3NkfWGW+pjBc;d++3 zJ71nUvlQAvYI7?`Qc^+-+qPOPKM6<6onfLej=EH4a%y{eou-aGq`6#)3C1yLAVtrn znOVRCc6fKyaC+~iQTWg_<3<=I#QK4;IJ2h|iA=9jMZ& zuhT}DDUEe&zZEvPn$(Lyy{lQW^=+-kWlIL4%eb8$=6%1|kI?#lfnaGE)?lc+ogr~( z9j{_OVcMmZVp$UL$9;ou@>-9{x2YmcarY9?xLTVG}^`M8w! z9JN4$i$L-l>6lT19j(fa#qnvImGW{ce>MFbH1(wsoE$l}^T;#L!L+25hZ@ zYvQlX4Hx8-PH~xUk*3|sm0D9{Gh^h+!f)PPh>PdV%^@Npc>VA?p;!DP7UP-atk46t zczZgE%36sYLd~HM_tdw?L|voeN(HD+f#U3hk}+*iVyT!5x3)7avI8W|bg)tfX?d6g zwE2V_zmhYSWP3F{>S$(ENPjzK?P6#>VLM`0=9W9)PMy5VVx{kdag=ZdTgQ)?I8 z3w=*jgBZ~>9xG%(28^#TZb};Gu*vV)bnmxTD%-m7S{!bV338c_GcF(0zVWJmqWLh^ zHlSA>gUcpWN9EN$g{itOg5s^k*N#rC&z&*I8e_08KY<2bWs(aX0(lRHUByj5SEi7! zlF*daSnU*9t-ELMgJG?sp>9El?jg4Xs+Ll2c}46&SNW*rmHk=Y38Q)IvpKjesNKzK zl>IF^+vZPfca67<8gSF-gFU- zrev){XVpyCT|X__dTf+UF*UHNmuhtn4<|Xdm8%g&ppUZW*axb0h#9lzxU`9B&!^?$ z>EWz~E*AD_$zFC;X~Q==>-O29s2XxQUD9a>=TX(H3)3@wU3o)Ghs&rdP7C>2OLb<( z_UmiqDV>cZtww4143V{-Y#n+}6qKhYK6#Bfyd2!@3!Ja+P7OAhOrd+@kgGJSoXpf8 zOvpg%@$NpTT9iRed$TMko%w16M-Vkp;2Yw6jq^`74!d_=5Sb8R?P1F;lzzrrsRC1j zhYK&Bh4{|cccy#gIgamGMb#~u5#Q%MYcp+{`miG|4TT!~-16M=4&{zluJOxCMr)Sp zC*zQ-6*F}VNpgk*)jNh9PxHO(W*#yY#h|kdXojUGN+~yLs?N6?p%t!nVxbzvze(8fQ`1exigbn>%=+YtWC+kFsGmsA@!CNKc7&)Y3YVP z;Y58M7~&ADnaTO8)xrRbMOws5%|zLISIc{gNUD*oRDpznRJsJJ9Z9O}M9PJwrOe2< z>7CKxO|USO`^BP8jOtUok<*&67WE4Z;_iQQb@LVWMQu4N5j4Au-X~^)vhdB) zR(DhLnO=jN%q_=Wn>8+(8yr6FTC

i@9!6<~2L zYnmYhNFYE60fJj_cZcA?-GjR}-Z%sZF2UX1-5r8M>_ptlN=M0 zs_q2}m}pqIwpDd@lA{+MktqB3viY7?N|oPZl#Kd)csSe;%2pEIL)aX@_xE@=7(BU4 zrF6Ag#+_6YJw{A00kEp|2STLD5Ot{WGclB2_rJepZXwKcR$ zPck>}10S_NdBqHlH{An-yqA88{1~N=Z@s-D8gwRpiWVpsF~YsQKOo9(j|ZPQ#a%I%#_s1-r*37YgIJMRVYO>h?=_@ z1Zoxsp)s1|zq|1eJM$Mq9s&G1e@&O^@S$^LY}KmS;}U?=7y|>N2JNK!%1Jg^z(=!IKi`J3C?Mb>4X!` z1IMokFg8{Ck!c(o=LtzvxM+tjf(E#g-w)o?dqkpX~RWj28Ud~edno@nBl97#FO|TWKtoh zXqaZFT&2tUj#fK-UC%lxjSxe(^I^)>W>2=MFh7wKW(MR2Qk~KE)3u_VwF)rb-Cy6_B}4+QQyeB2tL_f1hOm6p|G9f_yxzNu99=Sf zG*drOfSCtBluK<~hm(wTKrvP3wfj%}e(2~KHa7mbgCNt*NkV&p zESuB0tO=59y(o}*Uy>*~odx7#LwTzN5)Pmkw4jJuTlcdb?2DFDL}|&d(pbe_Q(b5& z%O@%VPeus!36T=z=XX*OV6N%MUWhr`+5mh3fpk0rqS}f!4d{n2vahL$GD*FD==kM) zTGukPKEt!f#P!zm>JJ0CT~v`O_kk=COj)dYyEGsPgvej;+~ zU{=w1A!?aI|B#@%x+r5N9uc|XozC3^mc9__hp$YB>x=b#u(nMD1Gw4EDrQm@x*Y5V z^Na4&n5vf{Vf9U3V#HHhYF*M%I@h&{vWP6wkK)Vu zSY%Sl6MtC{birPF(nHPn`zY1=-$auKEC`eZ=#-wx$$zN0mNP@QWTW)Wsj6o6g|cnx zz-mt0UEAs%k|1-HWO88x^~vjV@_C0cn4lnjyK$?ej!TLq_olFle6p>*LX+)V46hApE(# zh%MLQ3-zJ9+p&Con;ipEf8^8%_YnDcytf?kPV$3?7UBJ>oq zp2r`R(cxtyH_<+9Dnx$hA24R<$^~Sl#73yg%O+Co^35@z5>h18zSLI{aTKxmPTH7R zSeYp?uJ)oF_IIV2Ry$+?^YQh>R$s+ox7*?FEas!d zW&*p47gTVtSvvEL>}2}Gt>`04qR)52Bcl?3l`77=XKQxqC#18m&GYk6a&fTQJ`#sk zh3+OCc2>U9mlrDBY8cgmZlsd!v>Txg)py{s2qJ&ZBI!0Ed+dFmL2SBv0 zEe+z%%Xyn7d}ai*B@4rlOf@vSVZH>nI0mwfKXa|VD@Y0F8-&?A($D$f6>BIwH>n!v zs*xxaM9x%?dy>C1p_q4*dN_$-OOMRk6PTg99qYpl9~RMZNqZf#ltQyCr3)A(alzqy?#Q})_@336zhXFX*d^kI=OHG>$tI^4|qM9GkO%!07Ju-Uc!Tz^J~ z>(p7??OfcQn-of&hShrBLUfU1v{TJ#-)cbOb%^VgA-DaF!%bz-2t%^B% z72K`Q!Hv=SY@$!y7(yt>z9kkz+EGVlnaObnug1~FOt$4gSMEA*kYOs>?Nj*F?Vxj0 zq#@%Ct)syqx2wgb*o2Rjv44#LR&5Pn^d|a-*It;#7{`S`#VoPN6k{V?HvUWjCk7x723!MnnhfKQDon7}-&fnpvMyQ>%Z;$q{WMp|Y=+^r7bO~rwiFH{R z+}S@9pQiJ2&kpV-#S~wu?c~61K13Y3*J18soawga5kFjgyp#&8CRYl%(Q*EvJG&Xk zGQ`?YU;ImY9lyr+9KV=jCp25Z9w3UnsS|fYXRpIWBZzB>! zVM}WY`dMRgJjb?dpMro~Jl8sga5?pmQYq_>^MzIp_JhqQ#*ez1EzNaPwbfFPjtbHiK#~599;;ln#fKx z8#+!hj$ySmb}5`>N@K8IS#wd1{!GSieY^FL=oIbix*O(eG<9L`bqiQY~xniLb zd*J?UZgo_jd5wBeg(w=kq*z_2YiWAMQc`!XoGXWU8gi@_9FEz?f{ZP{O9Twu*}&s% z6_KV!W7Q2J1?XXJm1Z_rU`i_`E)bXI@Wtf|01wUO-V05Y;`aXjDH5gY9BkTp)?1;* z#kiXx_M2Paf@0-^5s_gV&k_VrEpJq0bVUN*0BUR(t-tmI{TPc^UESEjf$fV1Nb=cz z4>HEag337v!bg3GqJ(A54>G(7v?w*{r0}8q5toV#HOc&8wStf?UH6LYfEk)4V2HW_ zoki_h0ncf;eX_`|)}n%nwKK z=}uP-h&j*~jBaMP+Fn0)i22@dsiC1Bn_PaV+U8z54JSj7Z}g=`8B$;z;)?=OPO{j< zt`2%JoRPd$uh+hQtqt@~>zpw>H-J71~gdAjhX3EQV1ft&Vrzrg>O98OR4 zvHw&oHo=4>Jw^4KcC!v@YIhcE)L0`vQPq;)C z87o(gK2KBcUf)dZ#RpyOXqm# zHJWBt(Z&$1nlq>Y&Kc;4yxB{lwS%*Uiwp?Bk}=X3bQ4kG6@;&-8GEQi+^0G{InRdF z23&@hX#nDn9}X8&A15xF%gai)Pdc~iigkUQZR_hdXK8$+Kl!b)BwfuEyVoEfN)~ZU z0OX*k%4-WAT~A&n#9n)r)+L}JrV*j_9I0ho&L?Cq{i;I;9A_AJdoMWw6Uy(7$2gs9 zrZNDMcC|JYk0imjog%`9jX`&yqqSiwk_qib1@}U##F)#BekmD|on8_^t$-sdAdwS~ zXk?9jYJZ}1reb@AOls}yBxco3x$-=EHQkCvQjLplI!r$D%5iNkY9teo@)d6Lpl%mU zou{K@w_zvLf0yz`8FR8E(Sb*QO>IZl*TGBVXJSZw%W62Q$&}JTFGYAQ-8uM&P+hB>wyENdDM^CMt^U@2h@{oh!>ea20uMU-SAf790v>Y=MV!`K4*O~E_%G84G21cuNMIgsOFGTl^rZX2$;|9=-z^|D=Ba4mI9(;6reX*I4Pm`Q0;+GN6NiLL$ z5~pm=dc3{`KDW`3O376|a)RFP)Z9Hx;VwKy`1@DPN>j0&?h7UC++IvzunXt?S`*H= zm#HR*ew2fr@MbpSQ$h-iS*-K@Mv(T>)WM_OgKqQYr-OaA;)VL?=9~NT^Dr?^@swRn ziUsp2_v?XQLi%v37?3gRb-1Fm4G~PArtObyWsaEcK^pE@5$8qW=i$-lxOH-~Vhp`K zJ0}o?odd!=)wYdu?l)Kb&7)~4drb|_g;vvB6(af}h+7#ALK|;IGT1Rx5|Wy=nyyra zlIrr*7PtTpbjBxT-rs7XV?Z?XAEyuZ4w;;JDlnra?tkTHEx2AyBvmwTPh@>je(bq2 ztx;e44rn^=ZYrB|Q)y~$ugQ8$L(&lwiLx5{87lNP1h!_e$*zE%`mm>uR>C}3cOMp$ z{ZcV@eHbLHXluub?xyDCJT2nu{v>s+w0AO z+y(3PtHI_w8GS>-#JfrG=YC79noS04K1J|w6dWlj9G&^)1vyhrUMoJ8p@|pFoQV$O z$G#2f&@QLPUa=HId5Pi1UAmzku&%4FK=pzodJ9YjzN0>gfyj)>&f9ea(}bP&S1gJt zjcL2*!XxKSDn-KIX7~?u8qslc&SvaNcPdSei9=|iH973XZ;@*eTDVvr zDp1`%8)^)ots=(TUeCB+Y4_W7g}y|f{8bS8%1uh8za1pbP}G}DZ{cQ8(@sU96TJY! zb-Fnj%`_ma@lpf1lP_@gS@ws*tqU`7>@I+c>er)4%g!3>+VCD4ijX))hg{d&ispYj zuFa*B@bY7rrwF-P5#c%OT2&wRRKfQl92oL#G+nKSH*a6^n@s++Z85&l_fj~IId$f8 zIS;fgt)sO_?a!CdSEBT?|M`{+_~>?(_-0d>B{Z3mb*W)H<4kP~T72)Wc0|t2)gJM3 za52x<{>*LH&VZsuN$xbd4q{c3ro_&9(tR0ASTo1#Tj$JbM6>dwwj9avPe6)WTN3B0 zV^>CmzcWufkNzchH*C&Zj+p)KMD>@6s4y4jw-(}K^Me;Fb}1W+a<6^W?fg}e>CWTj zZqU1@7yUKOF+a=)-)@cPG7cSfwnSzFzxd@Z-gtgpXbO`{v9d}ToaFMXAS+{m#N?Pq z?cS?4_5XxF?;X*X@oaS_%pG&%JTc(G>oj*=#w z?n@R`F01w;9pVwWl$~qo9zSCbzngl;!M4G3lY&^Nrd(C3JjX3cZ*=&UhUOv`bM_!s zEOT}HVLzm<)cr8_Ht>#Ot3FD6U9hoU(00M4-a=B1#L;rQ{*A4rsTEe=9e9c}VUJU%2lFzR(Li z$ASpBH}CqLIUNUAD0-QaxIl>oJ-hYe4r!eV>+?j~aNWtpba72QBpn?^mw{gTIRV6t z_w-lMBO^G@w=Orm5uSm4P3o>wl%hj%tj>F9xmS@k#5UcD2Uw<$nTFG6KO=TnI^4I5 z4o6(KOPXGpf-G!ulW`sP?Jro6nuJ2$^ORvU*xPq{qf50!S*CwQ%pCw0J;)E~ZQtL; zT;srDeDzZI&FLcj?gQ27g+|lJ{cF2fMJhcrM&pvTuKQBEY-|HlE%7kr?%IDQnut0O zwL9*d-p!15ALbV?O@nDDLS4!iwPQ!|)Di6rNM9E7<`I{N^uclOp79qYNT8%{7A`wC zt6r)v@};#Z+*B>KdJ7=|UieOTRgnc^hc{XUwO?0V)mD?$lb9Bpe;Syr)q_u(DrQT; z5|-L2m@3W7_DHsH#08^ENFz%hZ#wpy3LK zyUAptPbQuq)7-{2_q{?0T=+RW=RnHt!d1Q@sPCK4Ub~SOMTQGLXBB{=9 z^t1J0Bz^$2CIPLZ6-dp|&tNCwtcslkJ1x6xEd`?pNFg zz;Z*15XMfk4m0k;C+4+2@!7|NmsJj%4AF5T`~2oc#|_?`=w{|kQwwV65o_mn723g3 zE$r~~RmLl!1ndp!?~!Ldv)8p%O|E;j#1MDz>7uIR+)`+e^^!0JhtFC(+Uk=`MMkeh zk%^v~3LGX4HaZ3DSz=F|?3+a(*<|Oj6xDNUr>7qD2q`#ft%g$J@+{dMwL=$pNgcYy zF?q@gyH(RsT=dSWG=v1b#)@!R+~3|r#!&#(U{(-zn$PY9CNaBL-nPaLWpGyqFCWzs z(wN^$-fv4`Lgj>yMuRXWup*d@epWuf5sgpvwsqr(bB66+8s!?hbQi17kFis+nKIl& zFxf8%EBNAq*jw5bK-|4I4Xe$oht!SI<)*wI2B0AP;$4IK?|a1##xw6uOr@i3bk`zh zQ!p1QUY!$hWaq@Zc8;TZkf>%TF`3RpK+nLepm|tahSHF#Z^9&OE=<;iB!;s$0o|pz`ng{L ztL<&*ld4U5h=P~yHNNovGRJo+M;K@c)2ELsiKzjmO0_P#$A(>I6nk?-Ds&yFGUtwd zp3J*5NdNkdk~*-lR!#Ezh3V;z_Rnl zQHkLWkQ%nd;=_mf`?k1yROXg2@E#zU1dGp}dqy+LX~*KhOy3e#(Pri~;=SJKCd&d& z$v&pdU40bZtUmzwFAX$ znrnXAyxP)@e&^k!tliKpyM{8tqN(#vvK%h6nAo0y;vx-~Mu(*hr4cGbX!$u|bn)Q) zo00R@0h^gku0e~7=7_LW=lSl(2mo}pVe5-Kp1^{j$^R7{{0HgfHxBlH%Y%NRK=X?T z$T`}8`8OgK1^_U~`3XM{#`%Jip=I% z3B%xga&|hlV7B=m1w8pYfpz~U1Ur~@53c12s{OYFvOmGq|I}b(TN`P8Ykq*aCBWhj zS`)ZDfEmCVOkmRmmo1=gZ>*~?D-1sFS3v)X&@QcQZEo{)!0!_>`(LH>{6!!FqnyF5 zk(1E2F$K5cPyXRQxA~8=gMR~z|B3YcCsB#ziGlK4znX#WU&3S~N6OY|-+s@Ze-Lw= zYr8M^D%Fy2MYlY;1SYu0(75SdEJ{gY&dk=F<53GD-Dt{P*)Auzk_g3Dr<$o9nWw!wL z+tt@udVd%!f7I8FB-*Rh|Chh#^n{c^MS)0cB?Jgw`le-)|aD)p^KIeGN2`lt_0|0;6V z8Md+WB=WENEFCrfDpGk#ZkZ}Nr}D4*`fq<1d6WDSP{x_{+=b`g0(j#7yCzb5F^0QX z$Nh90B>dsrL(T2%pN1^;4B%2129dE5aJzJ1`llgQr`NI&ip@EE)V%NBQ1YiivmSnL z&tre|x#PC_aliRbqa{-8V(z)n=P~_{n^KQ)6}p#W4;I!-bJO>gEeZ3N%mh5?d9Uqx z_790Kn}QdlZX0@C0fZ-n@c~-Tj?csMpFb(~hKA#k*U=a)@zdwN=p z%VGa`1=$}u8;afFe)nzg7(9=kD|jBFzwcQDe`)hWzVvyq{IpN--*fF?wEBFXKM%40!ybZ*G(pbS;fqKCh#nw-Nr%cTwCo zz$>0V*WbUF-aX(okLO?O@84^5zo&K0v*Xhu^m%yxlPAUV^7(m(;U9bxrsE##6UuW) z`@eS(|M9)-JvfG4a|twCx_XLuKL@d&!~Oqm3g|73vQC1>EX{r_Wu$*{%3mlGb#S}`upz$5An?2er9j~eY3a!(?tfJ z>EZtf>T%EX@MnR!|8Ob3XL|TE6#p5D{~L<`Ob>rnnfosx4)9D5|4WbV|8o%dnUDXZ z6!-@y;s1ml-Z|tR387r_uSkDCDR}=@(w4H;)3-LZFvKJMt>I0EM(6UJy=bgM#xOt&<2kIEW7=r2hPU_aKKZ;qhexa{qs3JEh97D zQ#SQK9l+|{V8L)YdfGpA$e;2^YMX;azvYe1^=%~efwBN|Z3}6+C++g5hf4a!hDNq{ zj8CHEBDUIQ#=5)~hGzQU++ekCZM~VFtJO$rb#Vm6h$E9zVH)+y2yzr&6A7pIn|?!O2fOVPj+jo6LW7 z`Hxg)dT{EW#WFMgv-woRQy;*-PuUp3*`99y_WG3RcQ=1`<>~&(ej3i-&wgK@{29QF zeR}>o?WtwI?WeNABg(?|SABoptN-&1e$sgVJ%joFO}g^J*4mE$x5Dzj)$jkUO7g6% z%unw(@Vil8+Z@&*alZ{8;>C*>9e8CANt&xKNx}B>*YgNGkHCM%2zUS?W9*v{|JRSu zKR$eZ|IzqYkCXWGAP73}84@fEY~T;MzYd0AV*QtI=sj0Bm+{W3$GEs90Pu8L@6?S_ z8(1;(uKdlLZ?9fc%y8m=`tkXto{Tu(Yrq#;kI?j=Hc{O=ev ztygkJgy?0s-mIir+#rPA&~>fP0ofBJ~6 z*7213E8&$;n-_GdfBUV$Z@<6E@-NH>yHrjQxJOjY2dur8xoB|qN7z#5W@EU&YIs!R zZInW}L#veK6hv83C4XNc6MZzQe9a-ZG9^#8n3;6~%#M6h>Bl1yd;%;G@W}38D&kYy z2Np;VaUKE-zV_`h%3e}hk_5}6hO5^=GDZ+|!bKK}XREIBilV9IDp#g2mQ2Vwz7Jkb zFp4dY7xGgw_KpwX#?5?{6=TvV86Z!cj?`x31U=fsS;lM>JlL5O%y7*~sRB^ib87byq_i;@p z6MGF)JNvwmqS!DBq+EQck*AR)`d$iS&;^`k<+_NJ;V?Z><2!)%017mOK%SdlZ*UShn`d`Emd(Nlik%tfG`&bf9hwiMiMxC}V}0cI!=G(o5kQ1M zM8tExDUsKVnc+w~dAyWaPOi|l8`3YRZ%`~&ZZ_Kv?+iq>2c@tS)M?i@n2ib+`)46{ zg;J2=aAX_VYPaRyaT*@4zzQZ*P2&NMf78Lq;#;$;r zD5Fu;ls&l9Z*fXCb3rC|qQ9_+Aav^(#GHas7!POXGRZ74=#-X?84vf!H-;jfpvX*M#(*Ev)NW^mydbnnrNEWR z6wSF{E`L~EQ(iBtshokHC-jCOLpq4BH%901K$k2cR&kkZBFi=Ltg1yqA%dn<)wB2#}Tv^PnoPx9MD(ynCH8Z1(DjGnm zvrVhO>0IIn+#cL4jq$O+eJBb^bzv`V37!K!J3y0|k=q^NT41Sq|! z9yDUpG>NF|_TqM#ml@kAYHMZb_--em7}Uw)pz2}<30>Le*u(bSV9K~8I=a_yMOKLIXSQ^sd zqv|#4vLofOpnSEXMAmYGo^#3$?M%@k&->(-`=2>z*0a8>?7rKfp91McK~NZLhW*?* zV=)O7kOG?iB(Rty#^)<{ja3i_6~Kf-JWE`r_L}|V(U+`C1`(k^-ByWHnd5Vi(VK}P z#-%+H;J2~WRdZQ-OT}Q4kZLf-vOLB!NGC=!fe8#bjinbW`nkclaZwmEjkS zDZP>ex|&YaeRIqcbM;fGsvj0LOQTY$Rf{NLifQQFiIC>UgjU6glj+_h1|Xq^>)l=w zpeBBA5i(t{(xIVX4#fTN`Dj_FqGZDWk+>{2YGZRI>*uceuyFgx{EAJ&ZLH!)rMi(- zQ%k44=4;7@!NpO3-WyS}*~xiD|Mxio+#C&7LMaIg(~LFi9&Z2{mol(>qMmF<*NlQ3?vBu{%^xY`n4o+{?r}6PW>B!#mgzLKs7q(cQU(t^AyFMyY1z zcq||!YzU%HRw)yTtqk4{P5AqX_~wwGh{@Q#d`%w6qKfV>dlfbJ{& zsUH!4#-673?=*9`U zVx#&@ir}ndeK_|A-lf<_s|V5HYP34o%BD(4n$qv7)^BQN%JTQ+Zm(=}gyMvnH(HH&MoO?rN$GcrI8Q=(Tln;7m6e_TmNOl!H@AAYql;;!IR9PRq%n|2aVeJs&3?^ zcljE3_xG*8G5Tzb`8C02F3fpXd&B&qyFHsOL#+nRuAf}=!eE}b83E#VGO|%|MwGARKPAHQ^^2#e zKq%o{8!)wLN48pYD6#WI3*$lzlEyw)^LflY1K63Vq*O*8D^zM6)?HvxG+HDmQOostXr;&pOj>1%?)cdDeF{Tm}-7k zNIp*Av>=oPaX&KcqN+UadJhtg2~t=ses=eRbEwzCw8KMq%g-2TmQx)Dtc1`hamP!#DSnrWa;y4fXKMP#?{li&De$ z4(gbbYB-+jkfjSHt;4WW_e1FRKH9C%r`sFdm4Mtv;d##{>lfEZ%XG^kseI}y_^OEW zujV1;Nm1*dlwX+rz$z2l^Guo>;YM|YD21ziliOMm>d_*;p_8K=&=4p%PgyPfeoN?B zJ(&A-Ji-nCk+0WcXnm%d@6HlT)QPjt6)+?Dt<%)ICS(_eE$m#eo2IlI)iNQ|8Ye#s zWUhx_sB+hqJw>g2sh^GEMAeq-sZN}H68(37TAOEb4-*Wd;KKsd_iJXV3~lY)MO8R(7S3%x+>TTee6vZT+n8gU$B1TDb-vA%RW{I*22Xn zL10xg)P=z^q0qnoS9e(H4sqFdiZ1UDs-2W&YI2TGsO=LLWu-h+8AfAMReoR4{Zi@p z85MWzO-4Zven!3fds0KMWsImOLXoh~sPJ+G)>K-{TFFxh*zZ)JqP=w3i)epvbd@KU zpzTJT=gTU0t{?^ym@vNMqt{rA=>L36RYc8f(NTQ@&v(la-6PRzv22j)IaBQkK#6&& z_A5s5qaE3mccL@{L};qZTX{h$RHw4xKqwZ}d1cEtb&-rO=R6kpWf$0bXP9LlGb3LJ zf5w2ccx&MJ_F6>crKI4XUpx{MVP(kCSn06$JgnI(*kIsWQl1c2S4lxx+ zQK)dDOi}CfzYt%mF~w1eu(f=qruAKT7^Q<>A1e*50>3|?ZWiIMEhISPXETmndqR!x zeDLqhF_t{<%rMic!koM3)8=1NLRPDszs!~zml^bn_&hyuj^2pDdh9F zO=-4QYL)XFA>=FvR4mMqn8MMRBWkcSJv|YXUrB5({G+9&PB?+-gI9(L$B4|Uhh|;4 z(?JDhU4drh3$&DQgi_-tq;VA-l`i?wh7B2%T`9^zHk4gfsJeAw3*EHr3P#tKR4o08 z_ig|)1xi6Iot7N(v!8dxVW^EGOzoa-N^Cu;OHAl%K}&YHJuz-mK&sFR9)^AMHp%OX z5VgLD>CHmLuydZbBTHqfvrta&ZIQMRtm$C3yxgX$WFH|aTVT#{WwUvAmZXAJ8;S}Q zS0A-<7n&X#uRosOJ~UF$?Qlir1Uu#M9J1Cfoo_C@vToo3u=NnP@MF}DANEy6c9#i8 zc4k??u+>&(x*7c>+j3;vT9aNiaZff=AcA>aD%#@8*z?+WeRV)Gc-9iNMLj_edZvon zCrq7TRvaO?t5kW=fl%LMe7Qz5P{Y<4yPL}08ao_0ho@Zm(KQ+or~A$z<0hqWvZeoI z2V$GLWj(6#h0R{g-MCxNiMXQ2;Ds9W$xGbB5bq5w64QW-abTm z)2on$)$TF+wu+zQW&ODA_iNU4ewuNJ&zO=WGnvlwYKS{kcO~U$7NwM=6s4r4T{PXyo`xp7#JGmAX z+q;(FCvyw?6BifcQja0$QFdrSc4KvAz-pzd zpwDD|G($8o1eCI50b#h)2>!AqaYhWddK|t2#HFiyC~KMHJ46bb%}!2EikCfRgBT>LT=4#Cw2L*2i4?=5krsxR0rXT&IK7vId%=B`iIuG>$c`f7<=#` zK8Zg2qH#?FGSPC?>SU#8tgzOw)fA`=o!Y%+W=fMr%Xwy22?z}uR>#y+PE*8Fic>f- z7&_g!AQz&D8UuU%;-UG-Iwl*bY)M#Z!ryO;~*~Bo^7jq=GTXvy*w07nu zda>V1*Gil!$|Ll^ung16^8%$F;Trf2@x?8X@LJ#_#?4Y@&JLb$?N!GjI%T8&>{bb5?P-wGoi1C`xS$ecumv9-S)LVmMx5V7A1SH(3l*f2iiD7 z;x7ziIEic}BgD?(s6D}}v5<^0$vt<}Mvw{V_dHvcCW)9~{WE;*4vB`4Mcw1AocMaH zj>LC&A04q5K61V&`jVfCJHlI?$Rq>NC+C3U@+(!M4MH?6ak;;(M*0@QwWr+El@TgS zQwHk*X?L6XYg5c9T-#VFW8lUZzZv%JN8c{y+1wz7#x{M%u#1u1LWC@1+pHVKpWIl^ z78x+gu$ZrL;2+|XTk+G7xm*)0ygS`6mNNvh>Vpd-CD2A}O?=PP&X~otGQM-L5-1}z zV$S<+UC#Hq6P%THh*XV>)(qbtbz$0K877h{24&1qXDZDY?x>X(g-i}BjKlQ{ zTZJk{IH6!PL(oGqReqSK?|*Db2`OdY(1zR-DO0DyqJ$%cj__QhGR40)qdtWz|t4a2~C4`^FB(C0B@;A(nSSPc_!t64y&Y)*gR*)T0Mj`J|9+Ic=n8|V`4&UNn-sv3`q{f{2mG>TXU^u~3Uh^jlp7}4~E6h7WI&oVq zw0*&PsXa8nYEcF$Z`Et#fO;9%FPG`u!aRaJOGl&I$cENK4xmPsFJ9YTXo!n+S8K~9 zUKL(S?QSsZW2DMXkcEwybhX&qNCGQvH*cB*04CKB$yV6}D{^tzBR_m|6uXF5x8(b? z%h116{Mab1`?7Dwtrk?7d|nQy*quJ#L%ralTKHaPZirZM|0T4{>q|*R#-7q1%ibjx z?*LT#Oq=l0OKu2;qO}M|$Q1udKOnx{Y5$cfXPU1A4^QjTyEpCM?yJ=c-va}2{d0Z} zb*7*cuaQ9oh{Y9A$`;FM$Y>}m$~MbVnN-y)H%YmQ8cqgJ431JwEM(1P+ zj~fOekvhry*DzNsd6Alf&{qsA>{jYWJh}2sq1XzCt0mzIY+cA(q>AV8can@ZWSZ?P zvVru9_W{wZhlmAAtxFMC!IVmOU+Y4-KiEIOe8oI$Ek34C$k#g-yM9#-$8mtpoG4kU zFh1BUY+>(r9}Z~y=ZmQ+EP#R-F=K%Onh#qAIbZVX5DO>{kym3>Q3Mx6YY z)2l%FfU0ABSvXhUfGs?oBO&rN;7Gj|jhct6JH~~SWbF@Gh`@G>xR-O2tFB=K3}tB5 zI^#5@k1ZbREg`N#F$@%nlP}guWvwe@gOl>$Zx4ksp%@XXoVV>4xX%s8TV?$|>@K_d zodCCG8*R8-ueR4bU2V4Ah!7Rk${0%c0-a%UeyH6d($LULDs^m#C~pS$eb{RRzKYTD z5jzUW@8&pmRDItVL4Lev09^uSkvX}1-zH`W1%N&H`Ov_;mGi!y_xxxkUk*Kx>%#GZ zaZ$KM3$qL$l?E)uWm6`JTrIuq{X)hH$XfZJ+l6iaNfN8BHm>K?6#~SpYjyF_H(~Bo zq9*Z5q79NH9SZB^_%fqRsEs0|EVAs@rVy<}8I>UgT6Qu&$r28X%=Yp1nHY zlQOyNGPE3XKqaX~+Y(Id)JRWRdAP>7e+vu+ZlSRzGzkk^)wBDY&XoA|KRV|iL@a6j zRJW)DcvWPDR`B1wneRBeD@~CnYE7A@AeE(oyZxAjZo8x1!}f+boM4*Xo-B)9BIm2& z*D;BdhU_FO8T7vFu^u6`_}edUwwCrZCB+eEYZ7HlzE!lUu+HiRR&=ucDAR^bst))z zW)aog-sbZ8;qLT!+3Et>4qdS0c1o-O;aHe7n7|YAz%Tq*w|Ac!*Yrn<84*HFaIp9z zfq~4MI-9_5Zh=s3NeG$SSQ(n218>sEzy+c7FP&?WIGEdt&0iRj*pILMYp>owk>u_P z#BD2Oz|D~FBcqcP3tX8tC)$JLy;9LjB@Q zy)QLF!2a$oed*w1!N^^E8RfgXT->p=&qr)PXjtRCp3!c?TRFxt;T(a}9&JooV?xQ3 z!F#7L9LOE158m4cOc;bwAK4Dq&dwrhdrCQo#=G-ez6gXA>id#kDfoR)b{ANOYB047vEO_PgGw5xL=P zg#Fy{r&sREHH0_XV;v#bj)-VD!~3D!TI|cyDZo_P8+)&1PCTJnWOSmHm7ldl{y#I{ z+eqlSNa%SuUQqC*rDJde9Gs2D&wmA>dor2)yb{rj4gz3xif7DcT;;xSA2Kf-4y|#C zW8@|-N_Vu#M~2{p51~tnuYvXM>l3HWL^hO+{fb8-PV#Y{iFXA)BT*?K8Am-<)iJ9< zjS6>{bikf)9S~z-YSP#X1+; zQVr^!4!B;Vdpz7X_3zh(8GN{)36=NG|7pr8I7l@CTPMRQ3SEcXdsd08c|yj(QIQ01 zdF>}AOK&IQPu5G;s~fNuni|?;AlQr6cOKuaQa0<#-cPv5#kK2mxc_y|(v^R;E1nL% z*Gfesx_uyhMDh4&O)ZTD5m2-D5>N_;|p=C>cG`qN@=Ewb$$Be7>KVB#Q z9VpE}tfg!FMTZtc=E#`d;fqS5YER6EP_%=@D|dxFe&Z zBUXrBWAlS@A3K5dFx^z$S@kO_n_mf*EmqPl>a8bhfp?_aV%Gmt;iCVbl ze9MUlFLc}{Bb-t5;8ea&y_FKEE)>~!#61+pzY~2^9FM#!B98823AlYnM(m8qY}7Gb zZ({SM(u8>@&LG}ga(6m0x=?$Z{VW0ccvSKMC1sDu=EWu&m!#8iIDR+bB_d@n0H z^{xA++tR0AJ=o=_Uw9ky{+yMt;L6;pPY3UK=8ZkxhwhP9je=FPHLIp-R%MM;at%{d z0dwMDRst~=5ebt*-x?6Q-lUcX|Jp2RR73(hYg9rp;v+uEMEc^Al6XV`R@hygo9-Qa zIGvovrDv)wIhT>`?>a1bt@OGiqPw$ z9Px>xOY2H17Ht~bm6y4xF~Jy-7*RTAbWKm|qCRPN6YJ$IJOz7Lvu3xItGyWuO4h54 zX+Pm4h?`g=Yh#<)Hg<&RSb&Y)N4<*NZA_=m%#;2sIDuRm>BQSo1PtkY%SpZ^rFpg_ zTN78$e8p4l#AvtSt=NTkd=yR8MjM8pj1uk$cWH5iyR5i1f^Ci16Tu@kM9W>?bZQks zS9${PVL$1#iKA1;`EM^OaBDBEr?h-jfm_Z>1dsDch!8Qfnj%Pj(SkQDV{+d$a*@b6 zriM3d6jqbPooE$>qD^#*ZQ_{Fi9_sB{-P3KU42Kj74^A%9Ii6g4eHRYiRHwj(0(YM zvElGZgGr6-6ZO_FXRD9Eb5nyKiO1B%WTGtkg|xxnZmiX(xul82fiU%d%%> zW^o;7f^nVRG0dD{9&Q%8gzQ*HRCa1wN*dSMl(_k1(WTF4i7_Z8N28R*tn-=qzX4aOrMCA}POF#sKaZMs1$Ff9L7eQTk zU3Eni?btJG>Zj|mm(fK8HNJi>-e&l!YJ%YIx4$o>tGa6rrt5kB z|L6ZaRTBX>cG1(fiW>Q0U{mMXXCfZQh zI59sphjyKSZ5OuO^K9wSk4pz{d;h5~ODFc-wyWnt-qS9T&s-I$!$Kg43 zm9>Xnp(~wApE9VhK+zSn8jDS2ZE?UWU<=lDeK;mF^jyH_5&Q;U0SR6KTzCY)&vYx4 zBqSne5Ct=0z!CT*e)=@sZna9fEuWD#O53GAiIqI=BPa@wjZA;4X{J1MjF1TS2O9xs z1NeOCJNS7jMVYRzLzl4gg-hJ?l@-ED+e-Ugrn{{>YJ{eZUJ;5#CaaregLj(;-s5@HgFHu=ES}Pv2Ou&BituKvV!8L)919#* zIW{_2M-lS6gKYlyf{-5S4Kbaez7Ps|9)n+F+*+WnS1dqRp^a!8dJ?^i-a-F^1mp=G zfxkG5g=0-+G<3|w5S0l?hlV@4u@vmy$B~`&hqj6EN%3V7fsXEZsbhppsm-teLUQ>j z`!{%Q_Tb0oS(`S?h7HeHpCNL(2WL8vN^13x#Ucj+NER`%?Jvj%Ux9)Nrfo0a1u^&`=h;}Ngb)z^<^9$E0+AY2GTyMDa*o_Ngj^3L6V&7AkZ*W=-$ z!raMTXefOfgRSt6hpv5KRrlfNUf;fS>3<$5eL6-SRYQ3i^T`}+{W`djSpP6Eo*wKs zH;5#KXfijnh?9(yP1D2d%Ob3-98;<1I&&}Q26JB;d60uG;>O6j+J~cuqep6=uYD)- zPVCRMABIoH%+rL*BD`rJnUp~h9UFKh12aV?cYra-F6b)4dk*;ZR66G`!U+Sir84;# zTm~wDi2kmd+OfsZc4{$f_kq1;XfDF-c)X^!25qnDt3fq*{J;W!Bc6aFdS5r{IoOvw znnO4zfa4G8jwc<+;mHzeJMq0asV#>(x=)ej7|zBp$)$#Rnuj`uh|H&KS6`bB#ElZm zg|&zl)nXdU8DiFW+=yu`&DJb}0STXJro6l=N6A{eyz39rdbBYLyyTKq$?+5@_Y zG!mI)CC2N40-gvaQxqKg3wR3rMgH^tf^7NT&iEieuBUv5ZR?^2)<&YP+eHN?jm8Ar z5rT8#4g9jkeb?PLxAn+|-mY6pA8olTt$Dn*HSSo|l3zuNhuZF$dTr;A6?hBbmIeLzba;f!`L5;7%2?p zgQUv>B{0dNON=hTDl>HQI2b@iG;okLn=F7eTUgF)!Kn4=c3!C9c|l-Uo-+#|Xn~f; z;9WRpHo<#ziveLuE1srUkP?%rW}+Q ziPL$=d#vYRw7ZGgv zc4)4)!nfM6*0&jL^=|du$2=(ZMFt}hh(P)^W~a+7RA5EHkZHArG%O|9kmgl=46m>T zJbKSSD5N=#U{+HYhmPkTz6a2Ing&=LbH)P|PC3xa_Ysu9&oN3OP>*y*Pz1y8>w_}t z)1XG)L>EK4+$SU16FvfOgD2_i9_zrtNbVprNr7_=6Cggvx`4t3$0ovfn}ynx0Z%O; z7iH4ZEj_RY^@KLTO=wey!|X>$4|CpxOE1<n^x@?YgUK zBi=-M`pi}PcHMa8Keet$8x+&=90 z3P{2fwO!RJakcRp>n8E-*vS~jF>nL3j$P-v-pw`%NzTATJV_77gcb-;z&v&^6o>J+ zgk|i_0}3z@Nj4x^AU1_gEUMd0UQnf1A-$@zs;_FWimmdLt%M%}4%rcMWE{FzK$W|X#YD8iG{G1P-69iE-}{U%Q35S?($W!1izX!rX9KE&B#mUv8z zV78LgML@ISv#2q?2>R@yMIda(4fsA`t{R~)b)OK&Bz2%52$BMe{9F4Aib29+?eF~s z$?`+XIIRqSCMFS`X3N>5^;wAX-tX;$0~d4qb-%uEMRfar-1y?spS*bE!Y6Kl(pM{n zU$jq|oSimz%MBaia}CR4mbU-=?=6=N?tS>Chc6v~{)2F8>Ehvun=k5ow>5qL9glny zA{d)-`WUktW6T5w%Mpt`JviV{#~bMWRea(Rpn<6pTY+xrwDehi51&VG!MD($1)Bhx zAh75b1{qka!+)uJ8C1bAh-EAWeM;W&9^`Pxy$3Ofig3rlJ`*%~%!VWA1Ypnyx*4!C ztF!HFA8TMAN5_GA#7-m!JVry!DWVlp@=&U*>Ni_AJTn3faiw7;cb(xnjvavo)&f0v z-qnLfBVe(HDGI^<0w_DaorZp#<&c^Kmb!f659rNOQy09gbYpjBR@QHr5&z~1_F1*I z(}Y>{C)j(pV(;~UILN~LssA3q`Gc4ZW~&lcW!Hs!O}*w`Z?C#3)*IiNeMs5u{dH`> zyx)5${#fE!=1&wezm=32U7r;vr zmu8oP8sEV~~5B(b{sC)sW6HbXDpD{QiDvTv){=DLHuUAWbDyM3qYf%tC{ zzsVNagThJEhvt*klZlgcmApkvG=c^=rp_=?0A_E3rH$;S`3z^MA)UkGZxM0)5iyR) zk-_IOju|q@>v^Q-JM(?{!91IfJdPh=Frun3qKp~0?%wWZ+@9PK_{j*Vkrd|?CA6Vq zr^;Clfe$2kFosNBDjf*hT&&=XX@&?+X?Xu4SY1)I2-MnftPis|_yFdo9@*P4<4^mZD$H36N3GFUFIm!?A60)*`^{GtHpQo) zN3QFebKR5uJu`N#xZ(UI-P>MRyYTAy``##QXuIN?)orCy+2cXwX9bKTsj4a+h2 zU4RkwAodud%FkTobxc_?g2f3fn5tSiz7` zw1i?{0x=fwh9mGYpg?_pq8-O-%!s;}j$IYQ=>`Z8Ls|`(8*ke0b_0t-n9mEPL%=TE zLw3eqgqLF$tkf%eD;d4Av$C&pu#&BWggZ6-G~M{55g9$nA85LvvZxy_$EM~HQj}={ z+g9erhO%X=)Tm%H$K{wW9*GAn+9KeWNRlexvydr(GgTXI!eZ=PDkLVQkxSjj$BO)y z2MRoI1!XP-7Ha?}uKfk8;RjskCKaC1<@hvuM9d+~ijdf|Y*0#xg-wI6R^GjF+lx!q zKlkfvZu#4D_dbEL_SUsC=ly(M%YxdU`eJAmj6Qn#Uk~-)_|VozzJ0H>_S)s>@TT(@ zzPqMx*K60zsU|h^y{C`C?aW@xgKlsMU7uh)wq`oz$58SMi+D2 zoqbN^d>qDbjQigZlv(Q(&2C2ORyZdy+c{u20iNg}Tee)_Kc_06nC_Ms;%s%`%BVc2>2C2oKvMsHDPI@Mc%of$*ltuGF#Y{eL zMUgpQ4+eC_gvF*96p>i3%8im1)Os~W`!Nly7Jx*V{XSQ#(i-$5uo&X=V49@vt5_@s`1I`CrugqaX?V$@o z7cNj(4TK=hf7;k(;BvIozZtM=!B+5X3$ssXX>HB6g0>4swYDI@npkgDTT2$P6I4)S zT6Iw#563S96W|1Vb*NRE90#qu?=hwo|Nkm8<-m+<>jRVBMdkv%o}XNst2bV_lpQl_ z)a*H?$yLqX--OhRs;i9ZozpNzn$|lFO?%MM7z$^?dbmBzhCQ?A6ye(g+Fd_Vig5Jx zsnq!dhS(10V@H}fvUD{3K4?BQjNNMZxcq)|^N{>`$8g8{6p&?H;wQ<^Z=1OTLJa_eg~z)qS;mk^iW_P(Pz682bz3ClvX==~6+h ztq0@BgZf(45D&PE{E7XAfE1UC{5$=HIQTvUq{$C{Uz*{cA$fZ~hoX@%i<}j9HX8zt zECO0Mit@4@Wb0VFlNQ4Wy>R887Rg-h(LB)zBX3eq78~M*-c>EWRA|~ux9l#xcJPDJ z$`4+EU9UqPKD4rNekoph^^?-F_r8Kpe)}?<`Cs>aw`Imm`>p*Gr~LT0cU^tSgn6>| z^z@nC?PI4@H}>8X8Z(V~qSQUOCK{@~1y1dM1crBhUdnxSymZ4;5c7EHlhU4d;oVd>tox!#lYZ=ITlwtvi<{dnK6L3li(Ajfc##1x z>@}pdKJ zLHfP?JOA$kf0o{oKax(!CjwtfU&&wlzYbK3(sUICvF^l^1pI-3PqZ3U(dBciE&=hX z;Ivh!&NTr^4#@$ZFKm-5Y+W`;{`!%%h@RJNC{Te=An4x>KzZVd@PKX>WQlRQT!J79 zz9Rfa7bW}}wA-rNiYPPC7Jz{w`dGJybZfiyQ!8Wrb!f#_8bLf>%m9j)q>7|YMu3Cc zQ#g4XZrW@uXNa3SthGvNvth$CDFw(wP(J#v?PhtyGfjLG{-r77*)IY0bj(u^1th;; zl!7?^4(aAXFepk4@2+5IIdq`l5(UEYDR&*Qe#%B`X9QRp#RRF1Vo;2RSy(QVQsOru z=Aq$#TpAv`s5E=FCp#YgH3Hvi=(uS3l=wj`NBEU>wkB;b6H)h#*HVC ztJ5P%+^GKC#OTD>7Vq2ejp(1U@5gvH3S*W!nQ?New<6$*x{}UJZQZ2kdAYgpV$b}< z?Z_qrxp6j}AMI@HYV2+7YZSa0Z(TcJWZoMMBt2=CLrlOOXv^LZy)*iDmJc=RjqQz> zq05*~LnqhCcV<>|S9`Bky8j;S6+9*`&H?K;lGa1`ug2U&(CgGP%j2Ur+lAx`dI z)nnD1!+~a3np~;u9Zqy8sPu?sDPD-9Re$IK;K2bCIMHfV@}r zsEFg@Q7nqH@uU*Z!ZbfB3nPJemdU|UStg-YW?{xqn*}j{I12)GOg@YAOB{-t&Rx`| zbw=3H4SRZeKu`DgX$aXyqxOSvzQ})kpb!p=u2hl#qQBs(q-xdW#9-N9a3Qg}$Pf1y z#9A7@&MYYq2;ny&2LTbqPdP8)B3hm{xxIhbjD2tBxtrL)R?1SK|5RO*5}~(Dm(A>V0e4Zkfdz00ifqe%B!3xDg%6 zR$9eC8m3X2Ne6G2b_DK|?z1114%tmY0N()Iz^r$!aox;pb=}R}?%l&Y#)xLd$|C<% zW*%cm3$iVW)e|%vKq`bsu#TF3Fm$IO>0@9Ky>q~p+ABl3$g~{TX1T|LEJY@*rz=GA zt#t_N|3&<#Csq90?d3dgnm*kW0@o!YBe&oA{|m2D1#yocaC@4`dJo`bzKv(_dmY!?>3glN-BX-G)-PkauMH$)r8if!J0(() z)v&LSZ)?`THB`kX(?uB2N5>Y|i>zqen`Z7v9Z5ZxdYgGIb&@@4{FeRJD0UgTxQ*C* zdJVnYHtanDZxpK#ubIt77}qU=%KL+=TMKj8UC2nKLFKGeT?B$^T#KZtlSaYJ8W46O zY*=>Lb>0z)yX|PY3(`FN+)Yp33>$~c2Vt>O9*0W~j z=cc3e%v1qeS@}oUPJAGeqw#rw#%DNyRrhp#!l*l|-HHOKhhl9obLNyb%Qmq$0H{{c`%yXhX_Jt5hjLKo(Ml@SqbCNok(}u+!)c}}G zzF%S+&7Q>0Gi&V=rQyxpxBsJe`b{mtmJ5-^bH2ag>cK6gYhK(jXG#BUFPyjbsxc0S z%3wV(tM7tUFa73!o-Q4|Egpk6ENRx_@m%c6(!%kL-~IN>f&2g06?2tJXC#aLhiHSl zF?&pcN6yv&4o%U?)Pq>@^cM$+U1Pc8>F;zqIn7Z#=BS}_4*Ue&L53YLOg$h>F&r+Q zKA}?(hv^f;-WHj85BTv>jgK@wYJnNIB0ie&(S&EyG!8_gwc}8&&xk;Cn${9tl81&q z{+Kq9)N4{lUrLewuc@PCzn0$JHKlLLD^muiu#PFae0qI5K1JAXONhTtp$=D^Ka1a&!t4ngXXxiiPv-xy~HQ?V7ysFG^~re5!|RH&rMWJEQB zDu8DELmdle-1wJf+Gk0IdJ=Y}e`X==-}BPQOWpeJpI*v@>+aS(*0C0Eev&O zz1pD0WVCjzQ)7;5uV{#v^?Yng8hkMMboY!zAR2bcb|kIZUtyDJ)Ucr-VyFetnW#bR z6#F-Gf5uv9%|A^qo_IR#u^*5>IsfifdX8r?j1l}6p2Z316FQ5<_0}h>2rJi(U=><# zU7cB*UswO6@rXsZ5jV=ZTSpD%pct@Jff!7S&0?E)6L>E7Hux+ji6)OJ1M?`Wx9aVa z9)S0u-TFav(73ntCGZ#U3Va>C!F&Whf}g_Ajh~u7vnZ~tE0?RwoJ#MlHPfEs?5HYC&Uri%~h;U-mY;4Hk# zk?5s`(?K<~}Vjp|qkK-g-jz{yMt0HioKE0e3pWO4vn zaxI2Q8PJkr8!Zr#zgo`o)-LOlR%DIyEYG`Ko;^y_*s;lEVq9Y*77=?AirdX`h6FMQ zO}}KVnM|7PHNY+dG!)So-K<;MEvVN5_gb*dKws2cUZOC+8%C6Y4&2uq(HvN4WsyE%5|djUin%V+m1GDGid! zASn#uexxjjBj4GI;K&78E63>)=iGTKJga7XHiiiHFtP@ zW7d%`Ra30h4nJ`Y23u>>)j_3V6|5YuR@DXJXVp`e&2Sw+pO&Qc^RS>#C`vpBUoK7m zfkq1}Wt!6$mliq-P`)G?a7VD_b2U${IE*n{i8cEnjM)%)x4b4N0~v-u2Mys}QVPjC0wMZx!0vVtK27KVN$;cN$ zjDSH~Kt=S7o_77e(rr;RQb#k=Q6qIU<1TGs$60}0R#&G^(ao0_X~#N9nw_FwJHVOM z0W1UUvP9D-88N;hf2zO0u+&z}ne?fyyp007nSUx^IpZS%iY89=?CsjhZ;xl^#GEu? zUQ(DFlFfBLyX>x?E`!(brR}jXp_R-Ek}StymA>}7Jr@O?6}78CnftiU@H}P^OXu-~ zq(CbW+*XezDOE~UY=*awgX7Zkl&jz}<;wJ0HatX9@~R%W(>Ez0$o9pG1) zhrk1wzPi12FS?&sj@JFf{kHN--H`i3w*HYHPN=48j<$A708H>nm04spv0nno;gs#SC(iIuS zok=Tcn8u&||MK-E;B8d*zUQ2gX3=OFjdqQek)_dMS<)z$9LsT{f$UC#9b&R#;uZ=4 zOk*2JS;~zm2_e93QYdA4EeVt*EbnD=g4v4Ovb2=Errf^X6fPzGzLZN#Q_|OzOAEH& zIcMY~wC_FV)0{J-8A)g6zyAJ90^v|7fCQcgK-V)_USEgxYp7IgQq{!n#J&X4Wyce2 z;$dS3oB%ZT?)CY{)9ox~*25GJMxL!$d<($DZ1q?6!T ziw0}RAuKUy+L=<4y!JXYH&QJabk~BCTmyWt78sj4dMHo}=-!%{luKW0Ai+@$I}DZ> zA>>DZ4@FE{JA_y*Hyw1yo(($W>(de;L508tsjtFKJ? zz0M^o;0XL9JOM{at5W`m)>E1o*HpLA@(`sCa)|vVQKKF;mSMqj1C#Od#`hq7EmXjXNQT)DMHF8iTjZly0P|o8eAK^moge`e$ zxl<>siMV4nYSV|ZUwU*}LSU_fWyBdo4j;ht4`>>wNhK#wZfoQxVmG`-iGw5MB%qHd zlB^(y^!yb)X_-z5Jx_!N8*9+H0s{|@Dk5hT9X8ZeZHLvu;rJ~XxG zU`(zf5~RR-2Ro4;9>(NoL~3MupfMyS*387{dd`{f#veE1crVYlVj0kMWZ z=BPP*ddOQthI?8&odNp^JyL4)AVmO37BY5$rxu=of=YzSJLQapH-p;cgb*1_iIp!H zc*%bxO$rPtaFCfz$P6AM1ep5#8%nwxQzN0iS+i$FXIYni&N0qu`uc3UW!~p6pP{zB z^IJUlS5XY$23lpNfVU~dmG77La8KEuk}O|?H*(wH9h_w@@5lnimt}=uC+Wt903$J^ zK*nIK%;G4aig3NEMvW+nx;se+H)N)OERHllbOO6BlZNu2nE2~-oyrWTkb0DG+LLzL z-36e)U;%nLJo8)eOtLu&Fof_%=6wYau=on#+*_Q#6$=byKX71AXLJBCtdGV;Sm8`D z3RaA62_(pNCN1i7(_$3aEoIW^B;?_3lQ&I%H2LA=ZU6Dge;xkn_HSNu@Rh%9|0?!_ zH%|U?@@JD9;5T6>ocq@P#oP8wzBD;{@DA7v&x02~a|gb6B!ZZyK1d7PZ)P+W@vXSG zqgpCnAG}VvF7oy4VDY{PcVqCm)C<{nm3JfWq}WhKDrVC)t(KXU)r)I0S7ru_BSrCb z0K<`7WJ%=TL+>iqJz4l>>eqpHQoqg|%YKw%BStcs<()*dH^I2VC6ZY1`w~eIRa=^) z*?O`&iIPdq*PQkHeTe6H8HA;<9yY>*VQYACv4N+9A~cHoifC7Hym++86k8xw@sKKW zNL4&+aycmvH*hto;hhf`ievC=2NMKH`+^Eji256jNv6rx*v2;wvC; zrtA&FVZ!8jWq&j|#{J>YKs3txn#Z_59~kg?DXaOgQ7uqboBBZE8TgF$T>7QLRLaMy zTj1TPEt!W>kHg2%6REw0R|?1Uj|)=;hYb8MjN~k)i*iS?L%%$AWy#jeqeuk$;)*NL z1hlLIu*>XBB>e;dNgAj{OQo6+^kR28^(;~m*PMHth6DoS=W-I)!}W8_PMWR`lxHKA zG59URH6t61Mv%+t#Eu^?(*%pvW`f21?kY&^O`vXUR-nWosSO%!gKbCKn6?T}X_2S* zfv2>{H~D>(7Jc-fkJ6&=;p&&3rw+{*5M8N{U?zFmRk38!VW~lf4fBIkJ7ZU7xRfV; zO(-m#oc3X$Tnh&qPD`HF{Fr6XkwQ6^jBABtsTG!EcvEa@X$8rYuFhzMV7h$d)?4AQ z8O@`5Mx$jo*mF5`z+1}_&&Ep?2cDifB-M1ug;fPKH3T+Ti0hwfv5axQ$7V`Piz@c6|RC80^1u%T^?*-k4*j6V#S)J9X&|tJS5-O0$tEW$|PNuEXY~&-LM-8 z?v63Iz&P60-WFyQ%lhE@(E9Ltg|#}YPS8BwVYyDc&T*Y{lWQf8C-#chsl z&MmHO`8}3Bt&;3$byOVHXlt|*t&(tZ!J@|0crI6Hodf5fx;oc7daL+ z_lheWtE82=mHB8K#*xw*uPSZ5!QN1Bc+HH9TQ9C$T)nt$?Mx>liaC#{drITkA2z?9AMZHT;k|%wSHMQ3?1evv2yTXrXtd#|K2(k<(T#B> z7JDH|j#NSqd7Ck{+MQmz-I=#HJ1uE}4r~%mV2>nQW|CPFrG`eVsRCh~BwN6wA(h;( zxY7G=sJi#M-*+?aF|_Tu__MLRMC$DD3GrP;_)78P;uIF^#=@#m{5jrXKv6B~SlC;N zFTn+%1{cs|38Iku@_uvhu<6T?9-U>u}OFkoE@$Pcwnl%}Vc)r9E?lGZqx|z@ zhCA|ee*c5JmVErutV;aH;ZT&*wD9Uf1Gju@W=Cf7v3r*u|L+4gwg(;(n4kFcO#+eRNiLWKaU{9; z#26YkJOa!EBjBvdE^MsyfW4$h0%eUbFHo5)Q1L2InJY8}{PBE@$Z99K0>q@)`q=K6 zCH4Z!0{_$>4UtG>Kb6IP{OJeP%lgdl>*+osd}CPHi&gRg2SiteG|xRDbvA--(;?k0J>Ew@kQynuuJ3Va`TkKBHz{PM5 zegfjuaqE8k1d8xk)3VdC+t6I9=ro5gMMtC8AE{*74t6PfqsyWtwakp+6{c(d!F%qy8s(`={HM5E(zC6s7trf9T>z>P`llwv59zJTdZ~CsaCp)_?1p#&}3|6ZPQo zeP@9mTIn>Mg$*e7khWQEAv~*0{g%50Q%sGy@N6{fumIb0X)mE(;z*(liU_)5 z5oILufP|?6wR&BeIEw$qVTok7h6L8Z*?F75*=#yn<78*hQ?uijLMV?HDhkQA!(#-G z#)o*)CR9sZ!WzqJ;Yoo_vw6Nn%-XY_Y&fShXEWtCwid4F3)y+x5^<5z%dX~D^J{FY z?W@D9_1^N8>}A}5xFNhjxw>_eWfQxJ+hn^zyxD$p_y*;c$PMcCrLC5``8y+blo6z}!i8+fd-$h?r^Tnkdz7alPe-3D9pn!3&)LSpqxu{A-}t|Y zpGW_uc5NtqMc+`~E?C-?f!N0QR|}S}a9`m!2+R^;X?#(3Ny)NCSyfuDGd)}nzgA={ z9I#>K8}XN#Be{5)tBKP9e-y~GIuu=qSVXsZXBC;}L@4sLj7-4&&N@l(c#Zs>sU=a2 z79kSh1;G}<+Bg>D0Sgu2340Y!wvs8=D$DsE9UoY-*$7)p<^} zi(-?4&sM^bNKCNVDBz()@PSAv%JWSGOVdkbmgC4f5xtCe%N{wC&0@C)Akk*yc}|%1 zF#C8JKeGcywQQzO(samlL9giLk@C(m(_LO)?k^A0o#W+GWxo6&|DVEzqH-uKzJOE^ zhJQCi!`@>*YG>?EcFY<>R~<}{WZpa}Vhc$pg3`okY6RyeJ~(6dHt@IUnl?NJ&zbOz z2~qR;|96h>xm!+hcJlZiCv~o&ke&I5`IIbUU=lJ_^r~2ibtD1KCF_l`I;o1HPV^sxHnPU(32rxJq7H~ z9)zaiPFA^Fs^`bN`N`X}*quL;n!Ki9_s*XMKM7XbTcG&ctctw~PblQcAt}{fDL@NE zEm41ZwsonNt|Yg7_9AoX=ijqj{x5;FrfGUp@?R!6v~76ZjI_rg^DI7;Yu!8%M<0D% z4`2sfBa#3+de*0i?l6(%Q7eElmBM@KRYSidcuA;7x3mOq4&0c&F>_a7dxi~JLo5Qi zkMm_!y+^lNt$4LrAEE^%DK48yWwoNNFMx)=9Iocp##U#0^lRB`xofl6HV^6}aD?5$ zZOM)_kLbIazYo8UcI&T3|5x<5u5M+waoe&CP0iFdZmmIi1gPnRL(^mmmP~c#N190sq+4-)RukKaCZViQ%G_ zL6gA?h?*7n*mKmfcvj7jyC6HNYDUi(nV!sGW+byS!)3n3F@7J*gx4Qgn-RLbV`(%c>fVW zPXAv>7AvKr@Ymt+Wfyi%J|9V6*fKFrY?jHp&o6a((`a6-)IA$2(AF7kYr`a3T=}Jm ziOFZD4H$SHYQJno(xz!GEvZW;m%v9aDMngCm?rm3UWNLxH^zZSsYMwyNCP5)u~^d# zk~lVvX;cUV!VD2jMwKxo6hm}Oa%PpFeS^#hV;Ny~GH5pgnQc~fAA~)qA0ZUtu?JE< zn0R|F1&2&{mn1OYTXliic8UZ9>W!2;+Pz5G53!a0@#p}Qk#&qabzlHVXHmO^JK@7G z)IYfhj!pgsHcei|!S27&o-a!#uS5)8$>V0PCqvjRpnd`3wXj?o0?BGgBSx-c#|SS7 zLbQ*yn#&=B5yQ)|s@6g4h}AM;-DyR;tz+QEmXgmODNPU}`DOkHEa4sM8jg>@m(RZ{{qhJ& zmQak1V&u+NB(G?LvKp_A)b|L7ZH%0kZvnTo-VW{(@2av<+20}6N9q_HKPmLcIYWA#Hvs1*oqbgxtjEF++w=*goHrXxq&L9aJ z2T|}}u;?T!);o<(gqJ-?+M*Py_`Z!X3k)}Q? zM&jTp4w3@jxpU@z)r0_shKKW{`Y(?ScS7XYOPh}Tf6~`UssT^*NhgQuCrPG2-d!^} zx@5}J1B@MFX=)bjHtKa3R%b<$R?p1#wsyn{yp2a}qDgH+Y*nnOz#a8Ofb4O_9TC`+ zoMo*=Ks#SiVYMR45h((lO?cD6c18fj^9U@^@C2I8624)Gr8XQI2H1~0P?sTTMUe-? zSb~q1@k)y%XF#IEVP|coiq~#RZ97_f*9u;EOqCrRF! zZhkSdt!<`>ys^H3_e}LF$v*JWY+#Zf>@#sZmTMFjqJ{sOYMZ_O=2-4$e_XYquBA~a zt(Erey6J*h5!n`SNp@f7;N|5Gcz;Xxyp`=sw_M{6edDUR<#{)(Ol`lssi~!-IHOWn zxic3(KfiVIr?<`Wa*odS@65Xw_H~9@`fH2U1AwMJn>xWfZ~Z3lgB1MatnAnyvl1B~ z5ouuc+Ch*8^n!NcloC;4Cxl84(Fu_hc5=RhoNu=W1Hgg=4{?9oUPHjAd3`|91TnD& zaTIT=zn3=wC(7yX<;SJhG20VaXIlK&FaZqy2z(v6hFlx7TGJXJbsFqNK}6`E{G3lm z$v&R^={a)9Zcl4wrW+O=<79TE@qI_k4tk1lqm+h^v4`11+#ljt56*S;wW;arnN614 znQfLQm}huyAqP8nZ^m(+C+3|O4A_B1@dGIV&-{{doM@1KtV{M<8S6)OKUNzlyIpeh zI0hX%9hMQi+2>$@LvpAN9Z$y{M;)94%a7+etB!u{M@t$tCL}M+%(t278#a-+;kr9e zqn*Skb7ey+BXVh#iK#Gb3r0XFDB2@D-iuojDh!EA1VmXyZIr?{Jn&m@#VkYfP>0v7 zQ4ab5N{sP=(E-E>3e8m(rs3Mvm zOASVvozPQgy{5MAd*8dPTFs(Qp8xYNPTx1u(ZL-0?xL`i9Gu8b{Kt&epS-f~WyOp2 z_X133U06pYP!sKf$6MgEmO|k#6mw8b!}>Ls!1X5v6Nm&5Xi8}!iuKB<$BQn)lQ$0& zy%jApSdm~Qk?*U&dXf^;5mGI>U#7Jr*ESa_AW5EZz_H4TBA#B$3hN5Cms_o@j&N64 zH(5u(NMclZO+Bg}2fwolGvPwGGI&vBeX>8;AK4Tfj@&7K)3ejPGx!924DC%Ggg=CD zaBqZu&!33=K|KwFEb5Y1$#=!?Qb&@flAK$GFHIc>Dt^VWSOQT%#JrAar#~^0Kp-I{ zRGKC@nAmwvhQ+CbBXN23eHV1S;nxH%N`lSa8X1gsxrP@hCf7zG zu`FFm`!>a6U0;6WvCrlfZzR#Uc~d7WRxI=4pn!(Yv6s>%noXKxgOmh$lN>2&0yXNV zBG50Y6nZ1nr&8!mrDx-#pBg5v79>}wkrzn%GBF-VVM1Ic!cg)Uf)icj@d6D$=9U(Q zT0sCsK)Sy~7MAb}Hmsiau+1}0Ntq-pH=e|WGQJ9^BEyhNBZE_seFDpEOlByWmKk|v!e0_eTIz9V%~8Hq(E z=A`FAX=?o7!iAL*VW{)-#Y%ta>z1!u@3f4R_Ljy=oKYGnAyD!+`|=lAFXDUi-{H7L z98^m)Z3}HHZ4X$UY~Ees#!IL2NL7KFcmeZ_)$Crtg79YK9@fO(95@*CX5EFKl7(KGD-#_Bt>u2Kr zoqpv1DA&Uh&@5Z5kny>NRd#N1?&b!Lx$NY`a38kf$sfs78^%k<0#~9E7f5FXZ#bQ| z@EI-5=Twll;7yj(RM>27QKu{VNM%?%;Rh0wCc!{#GhmCs3*w@njd8!H6y7q%ThCHB zZ4;_Z_X5BcK=rJ+HSK_aHCc6&=tP6o(HnC|y1sM#(;wg1jm2&_?||-tE8$lP;^e6! z+j(he_55}F2G(7%VD@LPy#^O9d+K{s`hND_BMT$$OlU__K zn0>$tqRi+yl%M7OSku}`p)GLIp;1}m(+vPgx*!7Bxj`f)lS*JRG2A3$2mn#h+#IR( zLL@{yO0MRp%*N+&ma(Z{(JS!e&F2W$SjwVE#SO9kX(a{$m-{IBV?&^HWc;id5cQ3K z-B`#m(;18ON0{Ge)~KZjsY{&7?c*50^>d_!nuWW^@~Gv2g&|+X;iVCdO%t;BdgC#? zPBO)-#q2>=iwCEl9CAA2u`e1A`6EY({m}PnUmy1EY0nzW9u%;Z*9ZGT{h;6bE5;g9 zBiO`@)ci)I7N>>HbGs^hoG6|+CB0WR=$YUl&o9xhUGGSz(O(#k+|Tv%gLrk@g&%T1ah>9@{^T565F-$N zVlh8;wFwK*LZLg}i+Y7GqhYk&vpw{n=P}_i+ZcaH*k^kK{Uz<%qZ}F{ zBe{1ZkiU;(xmzq=p!|2)@V#`-jm>S?OxS%{HZepDrXiBZd=mR8v?b0k zA*uW@Rv^Sr*yzK40&(9-%yVd%f)Aj*oz!v7T|LSIh|tg)>e$eb{4m9XIQ(s5vSv35 zH3xo?w)_XO1PmerktGKdb5AkfYwX#~J)5~F&^^N`)O=E?7E;|chf34LVEzjp#u_3k zI8Jo?z!>+tLjyja=vHVQu$vPtw6~aeRxeJw6dpFvsQooHq)arVmPni7%Xi>qmu*|S zwGj8c`M~2J{rT`iuTN})d#qCE(zX@1p;>QTcip8oc(?x+!e4&`xt~4Vu{zam+)B`u zZUC5@tapPvI`15*pcSY-EEq(s6)2XVK69?c`>2>n&RazO*5CG2u7rh4H zOO0rbP2o@CbC5DHWC`a&>Iu-Fam)}Y7t`Z-{~hWPIGu%-X%+V#+2F4~a>UFi{EjWN6tPsVn|UWuWYJEa9+z6U$}dUH;8vpi>$Fk_6t3lHwVPW~ABukXy3aN_y*??X_sD zePYUCC)d~NgHJ@XIV&dK%Vp0G9XPQ1(9o5uJ1Vh2Ygas;E*g=M~yx)xpoxcdHgxQk|n0K*fF$*jPYnTbMC$jACxj+428v_c` zI<%>I(+UKd*&=)4U8>3!O~=`_cZbtz4kg84N(^ByR^O9D;lGX!G{m6&W~WX5}#@~$hq>a#NNa! z2`16FhK~4>1U}aY?(BkHq{niXfe&@H5SHue@j~wytv( zLtVB~2=;_VLMZeydK<8qDVKpx{FaU7Ld)UumKN8tA22%BuQ9x>fn|(t#3dbW)OYB+ zbVd&neXiRHE9uo*ks0ZQy<}|;%;k9U=BVVQliyI>s@F{Z#SGZ1#j`L=X*UqA?8w6I z>|l00do*jwI?0*Y>F5ET{K=4spvhjZuG7~U>vrRNZe2$%jfnQjI_Hk>EPx9r;IuZE@M;w+i=sY1UoTA{PZ{a$I#1$=fOIxmvyFxK|Ks4X{(4p+Xq9@3LWvM zGUV%t`A#O!ku@?`u3h&6ya6PjZU1%>U850h>>CYF(8c=?APB8#ZAQ7ZTq!f`dFk$SQEN`G)OsT@GAkAVORCFMa6YW7 zV79d$0X>Cf5pZF?SB3L}3nK8M%&G`nxhmSB;M0{^U}?Eag-g1sZ3ddFlI#;pX9QkQ zS{?x_a?4dPKQK39c1)bk*J*6N`2Phr(^iT^XpqtzI7C&cVJk|QMyrxcaJf_am{c*ruIFJl59>TE@i5Lq7Y}8IXN|V57NCUYj&biF8VH5D*iJfQx#cY` z+8fSncS3GYSUR%)lIcif{+uR_0a^-6Fby`DY`BoD+2WM^JJbBstec%luYza#{n6Hq z?74e*zuLR@$nM+vf0TDJtd())zt;Zhe4? zSOv%6g+rI#vuZ|LFsPj`TpBOOlb4`vn{Ik|)%n9W?OJ>O=eM@5*7Vez&5J62zXh8y zz=1XQU$8IRhF(5Lb4S|^A|520+eX#2Etn$vL7Ki3B&ZqTydXhHsec=E5>^PNi7tHntg3!htGl|o)GtY`R;y(NmB4SySOH{PB*Frm zWB_L@n2PIOdtZ{MoAUDfs9|9=1D{=Zp98J8f?V;Ry%rmR38WibCiE3Ujt zRDk97NXC_Own!iY>935i3_8qXAP~fxfwuzQOsW^$BI-mhSns1uIazXnR3~A1g<_u}h;HNoHcp9#}eH;$p%KInbSh?KaXkiKhDO&Z@044ELINC;H zB?7xewcqZn^Zk=p8W0UJKixmm@0#mh=s(lX6g_CPf3klHn(Th0bBdLc=NZwKJIYqd z+}y!iDQR;yW2N%*Ou(*W>tgj-D@mebfIfQOdmk*Mk+S^|-V@%!an$$5^)Ai~9pMuCxo1Ublj zYk{646eGFMS-;*|=HzlH<`MZA*6umcGFoCY+Pe31!>mLjk_aUcMq?_wY=5 zZCCfLn4L`$3GSkg}80fhN@5mFkgj2pjBMofn6 zp`YVhwzMk4!%oFk8x7Bx4NAnL9vZ%sqV!A$8-mAs*G|BdUT-ezC!@zt!yxgK(F0HB zj2;Ep1Q~6(<)kyT=(J;OY7HE9CWj_-Q@NSk(cJl*ThEQ=Xd8Ap zn8bT}dP&(o;FRSqr_36pv@5FCi-%KabBEtb1@Ry<)nPrASu3bQXa+w@lcG8VHWcJ% zyvW-mrthnW1>6>g>db9|;8*=Q#V$4#V)yGC=$%o}sDdVyDP=}Es+?Ec%CYRRza@i* zB&dNAgWrbYJuq?ZRvw?Tvw>S zuWu!12&R6kbIqF0&cQYJs@=oOS6x+!d)bthCPDu{HnRbR|pZYFO-J2{LXP;`lQk#;@8?l{4+b`P_Z)t zcxd*g=PxdkOK{~Jxdd1K7r7)2mw4flH07~OplYFDgL<@JMdL3>RCVbz=s~5IPPd4+ zSZtGT@i{E5o%p(jJSr-}`xt7}{l#OLrP{^O;!N?8z$3|{MURd@Ocxmmx11?58dos& zVKY@&tHL9B#zLC6Q;qAEz((g$!1hZNC9wFL#n})FfhuvZ(;)_XL!GG#e_qlX>Mn|HKAXCYCozZooZ=JvRTP!1kw)I$N@t-q58D!B+%gW=pPJ%PR)^{+C zV3-K%)*)v!&UsG9j}xj~+CL6qs#@6)g2h%^8Nzs_L@FRAjf9v^pj03Z_zblrHazuU zku82a8kH>1JlB1~rjW%-apIUDiA0lt39F??_yBROGD${jAE2>9ad5DRNoo4|qaWGX zozdd*jTxm9y+mn;$>Gl8;IjVy$KN`8Wj5R6XLlGo{+fR9tHlhVHblu3z|9fwreS&!s6rwV?cmMH8oHhF{46IAS?15*0TN*cA`35nB?@esgLA^4*b z1wyQ}aZ06)Q!2olp@Ry9aUhEpD-x2^t~?*pi~{*05KNxJXeCe8F`Wkc2wLfDP1%0MYa*%kDTMx!#PDVv|3>!+NVpr1Bi^H$f`Greg|3LIHB$i_GJFQnfXocI z@&__AR4UE>OSp=_D!@~x@sze=z{L}oJtm2WmV=>?q^7l$TES4s?BHo{iU*`*X8X+Q za6Q$xma0-d%%gI8I;~GGaPd}dd_MbZLQTxGv8l&5*rTwr{8uXGA`&LDIfpi^%Dm*fXewc8i!9lW8DbQXs z9dL4YG?mtzvdY=*5FA@%SrU6;|2{C!?QgkgQu`=pC9H&pO&CjM=>nE7@s_kP;T1$! z%(`&Q*YrUlH$|uLIt94AJSI0=>y}!UJmSddLz2#z_;(X{ud~>i_ZV7~F$=_-Q#JXO z#nzX?FUSoi*0EX7L_8jr4H0Rq){gLs+KI7x8kdkCu%K;kZWg#q9zAr;Cx_p9>!yyb zoO;FbRrz>f`Il;C>vCm%HsTWlx)$k_k>q~p{e3@K8x(|alGb&)vgYOGf4MtT4e+@f ziiBb{^oiv&l}URdxTwx#zZ z+y)VDg9H!|T3!*qBC?UthI0adKH?NHfAQx}5I+g`)0nMr5F42aK@6kNiiZRXAgI{0 zq84@V(IRL@SIIkOh#p~B)z|dS2xwG5lfslRBODdZ3vS_9bQOeh1dpM?OQ2~2?IU_g zf|-s-C^yRytiaTQxJq#)$+yPPE=JnY?s)Y^19_hjhWKh8NpKoL}MXcNNK5#qP%X_F3!O zXF-ddfwR^RZx|-$4HFU?CL}aG5{7RZXnBc;Jy0JBwXj=zto7cAgWwc4|8Bd`H|!*ydeoKqb`+s5{e9UNoEwtF^oD@Mu34wl@G z_fHjO$%zRJ>@F<8Uwe{VL4220TY~^JQHxTMl+O`aX2nck5901|?>^SeUN^R#Rk}CG zWGu)!F^TI%BG?y6Td^@rJ`Iyk!z1`Pes$6@lJ9JQY6O}aj*f&>-X=#Ijh!Qa8=!Hd zJ+^S=cjV~E$oS6IkdjyS625b?;YXmzGoKzB0!EMNdd|P)x}DEZ>o2}Rt;1UtZ(SGP zctTTDMN#^lKg?zmz3iFs-$a>dOy%RCK`8ps%sA3H-AXC*^!vv<8djD^G>78_QjnCtIqpsB`OgKj`ubnA}c+{j3J z>*!W`dh5(qn%XLDrMKb{eEOO`khc+SXUU zwybt(CjpfMoeeql_zGe*F@YRw7%rXBHD2n;NnE%xn*J}P(C;YFAAoFe~ zGyo??v%p9y@F||99}*Bi0KpDN^Jsf>Vy=hP zN{;_wwdzC(BwmQk%R%}ot|KjyWYW@QHc9)2YLr+*h`phk)X5EWJN=6}j?Bg!;V&HF zHC&Xq;Y3MOl!K;dwBAc4$(c#gkR%6__RrFPvcK>bD%l?rUm zCaqM~Q9*W_ox@$J?zIUj+fyS?oXciKF~G;7Id+CabD+te;%2!QITr^i$hcKY<~q~X zs5J>OU8k)XYtCXQOR{JZTE>gngf-dgs3D7gyc#m7sEo&Ds7x$@+#bcPwFkcwY<+?( z^>DP1J*>#n+B_#z$wS1z_lg*LREQ_Q+j&cFXp`dzSCokyeQUQrw2_Jh1O`%gKpo z5|7OdyU}&u+NhC{N#lv(EOQEvOp!{`7;SJ6Ds~7O zUF0-Z%;0>IK{+$9<0|QBS1!5Rc;Pslr@_T>phC?RCNvrrB!VF&0w7%kZ$Q=MN(qAF zHbbU7AV)msQEdbJy5MvKeJ6S%`cLSG-o@n0UQh6sJlg187u^y0JbKXkfcSEpP1`+n z7jc)FO{3=`Kh$VDjW%)Zy9ttH0YxmkY{SIsLT6w<>Y8*-xn^8*E|2RRq)i&Kg;^|~ zt$2!nmpdr6i(BRj*KU~`{qXgV38_twrCpn@xqj!<;MY&N@Rq)~08{N%JO6>wm>$YS zg_)i=r8ncNKH^zu{8IFqK9md^fjn&_^1R{6%VJokl1S50)Qf8hi)$gjq@y^4cM)Gq zr&KrIwdDQwAMtC))QRa7UA5De@1XDSe1`u_;4{HbNAFPXNN^M5tu&0@gd{iPc#D83 z&F7fXz&1}w#Ep_-xflt6!}%^PrRixU0&p^vLE?LeAKS8K(bSoHZ~N4X2VeZmCl37h z+WKu*&VKI3dvDpmJo=48kKX5LU3Ts@?d#U(n}joP@8aB_XcV_atn#h@$T z(VleD#@Id#gDOU#Z`{KYJ)xH(e4Rp=dNI$&2y%(A@XK7t7o_JftFB(=H+hQcx%mDQ zK&U-D&@6F&CUV|w0OU^;{-J7_^g5_U{Xj8;G6Nm-M|eHe{#v; zwP?j(>`cpHtO!(pFTVz;xhZXuJ|aCJyY4HYHKn07TS^}(-74Q&x}CdAzN_@-+_%^_ zx!-&JU2AsM#(Vely6iQm$}t5i7{c&Qy{{vLL7thV%*-}3MXjZS#RB81NPP(Igl6HM zR3*^Uo#tnFdXk^!ALSYTEuAK+%($+Pg8TGz27%kL<7({AOb&qaIT5!=q({f^92hL< zt79wrY9;`gGn}MPdbQ5_Iioi(**tY z=N>jtnP-3b#GZgJraFOY)j(@ezvG0?7`4bsLR~_)W*bD+qN_|kZifZKQo0t{qLG&0 zaCqBae01OaQ{Ua(SLlf~w=C;w!wf~FY)UauuQ%|?Yj+x&NiRZrtAfW&#&k2%(_+i9N%KfCH46r3O=@C=G ztf6?U@t}_lICO?dY-qJQV1ZD$Lu3o_PGHILPN3U(C(x`WYN_oKgUUoy&=qsEOxxw@ z@_#KHExWq3uFOzpe{q{+Yj$Q^=f>hraa0>mjb^Uz+*RBw-K5==+1q)Kv`;&j+Lt+4 z{Ji#8#jlHB(Y~JgO6G4mzfpW7`fcs+65lJHihdv8!7Igc#rKMxdii$a_QK)NS3_S7 zFO=D9L#TrbSSi+Q=>+16m`XEQ%|h^4StF^itS1nUQ|WX7$h1nO(F~#|(KLD#F^D4? zjJNXLQe-qjKNEQ|@|y?~A-*4xt4eJzUWj3Gp*XSM3EN&JDubn=3qZfYc5VkHR|v&& zvAj+dLU?CHvpO=v;LO|BnS&4ezWx10aAPly$B!pjN4ao(4;xUjBtEyo2CaE6dTdW{ zWqr@(Du-6^{+35CMP!BsBe&BBf-;@CO{r~O?g{lL!^%hQ-}L!^M&W;KPUZ*de`f9; znmYQox3Bpq^XPjY-`SHe49VBTaDC6Vw}12|GITwWyHG`cj}gQ7e{gD{=2-jblb8|< z=#|ypyPbpwd(tu4ByieP(x|1fSS{hF+ekd!#?WctcEpD!O%xDm0=m;gvL`1ZiBX~| zD8dygkI6K!&D?7qG#RtNDguL<;c1ZMm#`fF?{GBO9Hq-m^;zgHkFVY9J?N#qxC6z5 z@02iNnMmqzkAEPHI1QQ)pl_%UPrh`z(^{n+mKKR8-^2>)joW*%m@4+rJ)%w9;^$nf z-HCQ}qBL+(A{XD6HBEgupEB1{ysuLZ>k@J)kg~oh2}l@c7>dP`?=BCrJ;+l@ccM;8 z&ZX079Zl;qI!);kmV_7dGrC)!w7$Kvp3aeF_n+OjzZL#$|I&oqTDMHCG63$!h!nHM zV^I>L7E>2r5azki5aE^OVfylKS55HlAga^lTLKa_ARGrKRIrGJWP zs@rwzDw;BDgr;gXAV?Y!>oy|RZ6wAu&J>{C%!qB4Cc*U+RkrO_H5Rzr&u6BwseafyowSa<+rgt6)ApPZI{;>_kN z6x5RuP0rOBO$94%y0e>gx>18$+~_v<-EP`#bh2wxXm{#ArRbCq^dY#zw`~n5N)&57 z8W$k4nHhw_G;2MrS$okb7s15r?U@({U`&^<)3U>(0JIlE#v-)vH2PVdTtX=`-i| zh5deCJ8;#w6S-<)|7EeOaJ3Z^prU*Iy;RXjy`3mnX%Y+BAFkjLT@6eQxK*n&HO z+LHxHln}+}J@#GcUHQF&YhoPjC*i4f+UhKtrDvI0-`D&zkr{0!K9hL3;YUy3aowIwB<9a# z65qIK-HscVe_k#hxw~&`O_qWJ^XT&PU%Yj5xxZjlHr)Kp0}rQo4Q+Vvp$|9L-7quI z+_CR(VqyT|;D;~%8$Iay0TrhUtG(Wnb`W#EByoc92}BtsL_)|NBGnL?B13H-x=`CQ zA_Tvn6GF+ymBeV+1@7(?^58l0!kN?6rN!3l_-cEt>m{zH#vI>OvX88)$H5j&fL*(y z0miE$LUC5$$O5&1eB6EOa6UmUrKSmP#&B{!L0-62Pn<{@`VX4X*K?ivau z6RQ+UWX;Rag)TuK17bE{l(B8+w z@gGw@u*Q~jJS|tM$+;w*q!4ns+_~^*2u+6&6_P@8p@qqx;JQYQpuRkKxc8BCm78CBsaD8bA)DFhwUwQnf8Dsf9J5?MSpJ{uFZ})Z6;|P< zn`^snrvD@py=7zmW0?Nvi)Wb&%$F#G-nokYq9)P2axGE9rx#k*mlh_|Q!Od_tmA*9 zYve>NNGv_UHem+a@;0u#LmbJ1xwd#6Pz;Yx4=A2wDd1yay~-0HUUNKEeYFT{BOI~g zT-y!D$)oaWaW$6P!8!|h7>@VpJ|&PdVz^hQOJ57e^NuBw*d}$2*d;Xr^E965mVz9Y z*9nd49>)Tc4}vWfP6@XC#-Ku|G#E7Vt8FZJClM_z>=uYK4JKCt1~DcjtF-FK1W8CY zVg8vjUA?|WdO+Wp-l)4ZF0>7#>&&*4VP-iK4YMh3t?o0D+&o%mhj_{`@RSG76X1Qk z&zB*K4Fl91LL!<%v*<Ma7ruER?c^RTH1fVca-I{8 zVF}xD^{F{na@DfJHo0Q>$ksD0B9_4#&)h6#qG>a*{Oj_k?pe2WUn$Yph=#|9il5xl zyq@{eg`dun#e~mJUpfBZG971Qz(re&S})#^BCUP=pk zNH$;@Z#v#+<`<7q;4HIr?%Yrn&rH=z`p0gC_r^J|wG~=WmFQryWRn_;f;}cSPg4gZwH@|^rdqq7LGnIN3|eVz*PKET`PC!6{ACDYIFplx0%d zwwmY_JMN+Oqy6-h`yTzX?$67IJG=v2FW;*)ADv zxSpbdyqvsY7v6 zs8ikrEZWVZXHG~RXu8AsHSk$bo)AO58DQ0_R4hWaBj79A1?6=J0HwU6EkU_oSy~DI zBrO5IKGX{Pv=XuGKL}Xnge2S7MkZv46cH>&c!JlX{vpid-Z+U%Z_Wn}ov&fE#Dn`g zA8K+shD&&BdAT4*y=wct8Wr?tK3xtol#`kV)0^Y73`m9q`DsOQ3WbO|io|tlMJ$75 zPNs|=O_)~Xr!U1gUq>%0_J*^G<)^IWQ_(_N?qR-UX3$?#ATH90x7(`^O&(l2Ad3p?cKBVzX?RdSzGT7VlK$P2bx=J2(U&C%!uCX5Q9hUA_9#9|1-f!HWpRU9XdGCuI$}@iNIJ#51Q+DCegI}p( z@QB6ZM~cZQNzZ0dI%Snol*k8CVp>h5(s=ki4sNIO7Z2FBV&rs=<-BapDugYofJwqI zyEt!{Nw_!Nxb6%N2b3`{KuVECoJxe1ZCym4($} z5j$QTRnbuuJ*~c|{wwB>U&vK4BZ!~WMFUCrrMFf|8BZaJ%2N@{3j~|5?#d%OKMfi8 z(7EH@17?+b5;Fu2Lpffj3TV1;9`ZS2G=VQI9A$|bdelPGkkL`Hbj!BptOe_g#aff) z6~wj#{s5}Bbm1%()AqHv0B+K713s*r)i8{Mt(~r1bA06h-n9c4IrSw+GK83ifp)GU zuFW{O)h&N?%tMHH=!A|#6OK8th)5ozC-RV>1VkxVs22i`l7##uD@7QJ^T%RM1BwXw z^jM_1Vghn_3?6Yzfey&2jGHQvxjZdW9;4tRE1if#g5x_E0*^VuX-LpRhtIS*PwO+v zG7jVMqiH(fIi+A#dLy#E%x>B32d-c<9Gh^G|A{1S7dewV!pwF-G& z>CL;!6z;AH+O2X(qWi%~mMW)YhcQ*Fz?Bxr&avFUBz-8jjz@?6hXaS@L-|9!FZo`I zy<)yn^NN)`Z}@V;e*O;M>pg5@pdw!1=c)|3hom97KR;Ax_I3?y@@_@*{;U zz4pL%b-OV-a0h$PcThSgAB-N1eVLt=X65fFPvuhqwdx0$@eO}i>kmgq$SU539B{$e!!vz0 zkH>N>8Z~=+>%318Y6>I-Q}ey`-df!V9*$OJB-b%8jta>G>Szk3s>a^z!7QCUoJCpH zs8pJ@^4peW)<*F!exQ!(ZZ~VFESswv;ddo zJLzr4K(m7DZ$Pe?2#DC;ggoT8;}Qu8`8fUnr5%a|hTLFlW+AX?AJhf__TbVgh|t^l z6JpcQMF1B+J0?O%fY)$AQl0tn0Y7O0jZ9vLO~!39TnE`@0FWFDI)&`OleoVr;U7T8 zTg(nNB}r_`cq`cvNJ#^HCF&GGm`o#}?>TRWBF#Q7(kyg^n-<<=E(-pOyHG#ud6~!SoZ06^0CPnE~FDorAi!vQ)GDcTRZFTW-BRgROWiH0?{?d5w@v$Y+s0?x7;KC= zY~sKKY;(rs0w$M9cD@NL1Ogb_F(Jp&OS1WFhfG+=eoJ6?hGcRzB>QEOnZ%O!y^^}! zwjq;NcU8SQTJ`?_-~W2Y|8>-ler7P4Z2nKl6I%D(XUCTJA~olA@>y$edU~sy^C2b2 z2ls!idFEh-lq9>w>71IGSK08Ys z%EB!2JAqa_gujEa+j9WqbMr3O&!%QjoU3Y@R=3ADtcxetyaGIz{vEh3{SLU1nW3%i z;e$^b@H|{g{Bu#FBcF0?E8e3yq9qIljZN>;yA5%>J7kb-k~Z+ua8|cO>qo?{_^89ydJZel-3vcs>7ft%y9Lcsvn{(NK*jTsRC# zWZPj#Nbh&K-RT%@M>izmaWw#m$Iv4&w}GPd3AA@1KSwXNLlHGetOdPoSP{!UChv5* zUD#Lg9H-&C^zU#7_dfaqnxqfoWh6HlNW)=0p2XvezscaqI4Ass|M;c^R1&oWk#Obm zPvIelYBJCL?)aXIGU_K5G(o3+F>evLI=90<4 zyXRS*J{X5FIl>!U%?FFmE$?1ZN(agjS{RY~nx~n7%j(P_*oS@Lp=K8TIU2DUjAj&G z@BvHh;!Q{H8%)G<4yL;H1o2{LN;FwbptZ&zxd$PhgKw%?6cn4D;;9qV3G)fdQ`8ws zcftwH&Rfly;sjX3OmL)!a#}i=jnoR}UFvm4*G6&#l1>N75EhMTu?F6$ffJg|8bXsc zX$Kj2fPtHsy$r#m2^!QINGYouc7FBkG78(lAFb9tPQZXZZFi^6i~!YQX+D3P4pmW4ZKt-GHnLnMNCRAd?v#Fm+s#9C*IP+ov35Dq_cn zKRsnHQ~PZA@R3utGR1TF@Rz4JbjYZO&ofslX}sQ;=d{si;yA*jQ5!E!T7pp*l_uz` z^Qw`70t~1*wZ&3^g8_9BdUAvD;!laq%{Q*^>F`hyEeRT*hf8-Yc3O?l)%;h9jJd?@ zSW~|EhM4g01Oyl$caqm>ZU7FD1SeFaL?EzGyi@jcMuK|Q7^w$OIoZ1L6mTLzP>?7y zY6erB^deSgJe4Pq33-*VbXxAVkQRK-KvkZzL%R!E3wYqgz~s7i8s6CkVg=f?g|;@1 z!T|KU04{TVzXm4=8Y4y)kf_o#*({r5)ukf1`HB3w1#{ly4?hiu*DRq8W^=+8?Hwx( z>^VZL-(E+=H6|R<-tn&f-A6SyL{ra|#`-SN!m{*}AF ze(BO2g&+xL0HFR0K(xRK-U$GS7#Rg9hCuSxeEQN4iKjKw=#5)zLRoln4LMERK|D$R zJE0}d5F1}4pppC*83us(0A~q)0sewIOH>F8K*7@=W?PGGs)?0fB#b1kxjH%lMFGUq z7uT*Ozowb~^A^pQkpQ^Rd-Q5P3F`3osx_Ny5`dl;qFM)TjW+{rcRmSdKi$bV89a>> zHwmcyx@rq9qfzJkYR+8xMO{++71X3z8cly6aH@AW)v>Nlwd=tdm0J_`v^WFG|5aOZ z(ZO%jo_tyE@4F%=&*hP zw7Gv@|F4U`F8xRGFD2bXKlJOFs6dB<@=3veG^mLRhOp>7DRdqcqjIs!NoJU0SNF0! z%%33#>*j7b3AhPlX44V86zOn18Hq-K*564R=!~MVG7@z{^BmG$?tT3~>L>c^PARlk zJ}wjTr^Bwk{xfj>i-D(G(>#&3P?JTsm%&ukhB}5FXXC;8V|Mmtwl!W1gBCA~)@tN+ z^weUBYOS|MXyc)tMBNW+v2MRQ=Ow0&Qo(};Jg4({kfiBkx4Ph25;!7Zyv{eh2Sl_! zqn`8!)CMa5+@$)PIl z3nU6t&8{t8JFQP1de>y|Cj7CcNgA#`|NQpX4zGG>AlVsAmjkjlmfPOpCO@lfRQA@8 zOBEM(!ko4F^-=a)o176h=?t5wA{!(i%c(2;|{U zFiy|F?2#5$UvkEH9$0Xv8momEu$H_w8RLLo@7INhaA*#(p?SoH+}?KI+5c-H)~zDe z?MC*NQ12krDeHuaSb{aTrYvh$qJy_o#G2<2Yc?YSeF$RP(-ap#784!AHg8lWio_E| zxUUF5113jT1AI4S6kiteb;85UV{BJLy!-W`g>oXOZ37_R4%7kCbU32e;r zVb+S^sMpbvkmNeye$Gv}-RK_{bA)8sYd&r!%%5glh2md9;v5cJMr?N0HaD}_xe$eX zDr>W6vgxdnCAA%)ls)GKSzFROkHe{(_v3jKu~?qtfuH7*XLRqsw1)%GifqmM2#9&B zRvQq=EAm3k1MxBvaeqW<0ph9za&lC3&UyIgJUc)1Mayxdp}Acc8fh9L<>BX_*G-;W zw`0r05w1MYEcc6c-Y=&PRDw>~Y9dD)&+QoqA;{GqBGxSVAK%$OHujg{btM5x66~Oh z8;I{Bm+2|H&=vdcI*hh~<_viq_yIzl(#|U1`$f+eQ%@;plpiR+QnZIGw>lrN9C1>- z%NqueVgmY@i9Z#qOGa?YRyP?lHE(4i37KSP^6n&=RE^scNS(Y&RT!?5yPG39bnZpQ zmCh_QYvYZn**{L~ONk!W9Si9BIzP%2U0_e(Ci+@$~5a z)w`aRV#)2@qQ$J&SL5m7Tak^!-l3WcgM1D+VE1JUQqS1c*@&FFb=WzFPNUcJdY^Ws zUdKDzGZKjf{eTPSCI0d1d5OI9io_5=$;H`R?Puq$t=Wk$KEgva8-pvG&F|x%S(i>e}t#_c18%koe_d>XM|9nl3nin<@t(ZA{RFI*#&OUtaAXJI%ClcC3UKz z3uk*!rz$>T`kjfu^A!ox3(odTMLei{w$*u8>#>!u%F&|2vBtV-7F$z;8zDdQdf!|N2Bg))2+{seneuORQ^G=SubC`35(yap=R zz+ulLPzb;$n>P49l%H)sqw)Ob&uBb28_3O+b%Z9QWJJHR?>J+&0*0E6^ICkP{5Q35qE0+xZ*U=!F0_JZ5NUEl}x zt=;QTEJqbxl^pI=3`Lx|?<-JSCZri_q z%Z}mxj~^;#Z`y5htXM^8E7eu#6Wq8?aIZVGd)>O-hsYg4+7e5pLctwCdFOmtIsdvk z7hX{m>+86~8z~3HdLADx{8R6PN^5Vu(7OMso3FVaQNS(+3;A3&+}?Mz_s;fy?z--( z^40sRuj?FFmgTGd|J?VHZ>012^k?zrPq|DkBjKZFDT{u-p37u%#0tD^xbX?%p80zl z&!+R)tOPUpd3D@ci8T{nxOnxDElZ`eycY-m=5wjE}8N7+KmQNPE{vy?)wLi zbDlylXEsC=%M*{_>>QQ$9KZ!X8EhH*FTF>eAU^;d2=52#OV@b$`%bvpvC~1^Z-a+y@IDhfXoAP|@HRa>M!;JMsJUK*X|XO&h@>b+U9F&BppABL zhI}8?&esq&r_QSxbrI^OuKIylye^}K5Q8bAkgpQXpaqrjDsEL&P7(0;*MIruYS*48 zrnj8fUtM(Y3#*6rjz>F3_AXtrcT`M_?;<~#c<|-j+rIVbwG$71XYb+feq!Lp(yqs^ zAN};Li~4VVeD(UzOqWr;Z$g#bO}+^{AoG%&v74aw3|v&F{r0q-u)CqhbB3HfX<|HP z+*UDze$hKO-g(XX&TB|eIQ&lP(sEdxqxWLbi-`qdceF3bdz7l!)JJMb-kGRH$TuZ( zXr$cucDd%&NRr;)TO_W}R0M;Bso#&3&@G7C39z{y;E~6r3u#D-3jMQ5mu4Z$x>Gux z%kNHO=g;LhOXfg;e7|lW0cE8KDvk)6djXrT+WAKI+*G#KxRAxP&Ea-(Wy)5b%_`?{ z1L9OBE#uZCU7%1UOH~pR5n2L->-Ko5(MqGc+)RuG1!ij@D6lr0SxRW}Okz2q7<+4G`7q zMGb|FXKe=6Yz7bSQ2u-4{EV$^J)cDkP{(44Y@h&jFhKn3g%`fkc$?Ta)*OSU$PX?) zeyq6(e&u@dlfP(u9H6(zHxH1D$)BRP;4l4C^C5I*&81(G&ugX$Esl;69|oE?kf#tD zOrXE+9U+bpj}fN{ig=uQ5|J5_=wmU_Mk7tVOya@#Ci4B~NdoyOwVX=v^QxPsAUdDN zL*_Ubh5-Z-UATih)r{?eUv5^z!@ao<|83-(09rt$zve%GwUc@Vfh1^!75sL)(YxHC znMUT>^FA&wZ$3;uNwMf{y#O?yLT7fOJKj+5= zcQMD2V$gVaX(|@0?99YBNR@m~Z>8dirF;1e+Kd#);|>K|FiVduTO|(;g00Jz(KZ=< zMXhlztK`_6f})z*Ok-wdrZ$6jP?`DJYu3ihIV=F^#M?L}+G}bPdJYT36b{iAsu!|=%Q6;UGx-+@59yW6>6X*AO7QmjjGm*LY@NMH2DYnp)9EOkTzDjo&!7ydzwVZCo>Znb~U|DeIAqK;ACq^)V!e zHV^^(!4vh&(xn?ZCSB3!WVJfDWXL8-llg*_(}BVCAe24MWU-VN^XU?p)Gm<+dq6CSyC&nb^MX~&Ag^H- z`!;BcMe+*Oi5-V2>)Y6zA(xC@;E$>FcjV^1p1@Voc3HmWB(|ktb*jK37Lv~tHr+ds z8caGY4#A34X3(7pID<};-jjHv2J|)p^ z*vpqI1&)07;*MLteoNWr6KyuX(@dCLe4H)sT1z~$W_#A$cqX=_9GnxKMfZN^W8G4z z&nqSku9O^$V6huKQ$&7-7VE|4u;#ajuD5~*z=`^V9xRdAV+T_y)?nc8W$&Jb(;N34 z?}fd^;-s`My$`bc*nNSAc-uWvz#~e501MLBEs^fk?!K^fEpse&+im5{VL2KdkTbwT z>v-H8;5J~7ra7a^iZW|ewIing%uMb>bfR^h3wSn?DkwR884Jqatm1MXr8^NTa(|7n z@G9L%KKnQ6yTc!)L92XMrO3q#n~osnCoVHz2|C3#^K;j#N^q$itt_!~VO*Ta~;q9;6hXwbtS>fsqZ z(itzSGgtu5|IwH#dJE%L2Q7e?V(7*14pfJk3AHQI-5Ibzu;gj zrREa_gPs!v1I&I&g1X>vo%CINA(eBXxQ3)T8ygeuy2}}`+C7X;f_vZ|Y=InT^APzK z@=*K03b6U5`^KSr1@|(U1tkz9vvp~#gg)BIz7>)d1z{d{GA*xIwyfGeBts!V_f=(+ zU_r4M9yXrEe|RDI*({!M`pygzhL)(P0|1euFqK;njs>y;)rxEcjk>pxB8kVuS1A<# zP-<#nhhr|T#6?HB-l;VNszvyvO%H7D4j8k8!yVR4sVm|2$6~anYjjipex~Bnh12CA}YWhj!o_!X^Iu8fM@lEo#0z`B0XrS~WT;jjkw)x|XmEOm@@ z5IYrEt<^xM-wy*QXz!a}9H-BE22qq-mVgO=Cqy5oQO?wAtIDvPL2!D}DTnd;JGC4_ z52hC)9GgS&Vrz996g6VAjY4ufgn9~!DRRyb+XV^_MnKSUS~QpSg~N$pr0Nm!-y|L5 z=;Sa z>PAw!9%0J|@?cBd^I#HIlCYe(KS5N&@Td`v=;1I4hX|;_bm_Fl{IN%3PsFHL%u)2Q zdC4J&GAl52UXi^5SO_wx%)X<}3!R$1+(I!IHeehr4Ev!<1*!!ibeZUQ^zBE6t+A0} z?;Qv4tTi_#x;jl(u^56CT8b-9@8s0R#mc~~Pi$l&5t%06T6WLVQ=u)>+ha(yDaqo` zg@}X6F27N{coV79kW8qy`N*2-e>_lyBnc5J3P`4QqI&1So_aAPOAKRUSqZOfKmKo(x{t!1jXuy{K237T=!)#*NRM0U zZWz+2zB+d?W?mXGK(dtWgF~ z?UP_#J#4YCR%;UYA^MS(xL+3ZPD!=^3(y&4r`2f{baGrs;>H}GUl3Z2IkT;*Uas)D zxf-9(s?|kxyxJGz^V9Jj+1k8_;zGA1Ig$g&EJp^?Zm})xpI;#D6$N&sU^xbdEpVE| zLJtA(Ns_0|L!R$5VpQ9(w0i^rc)d0)mUdOtUvk=H2@5+?m9<<;+La4xBbF-c7t~-& z74|l^#f74dD$TZr7F$S;Q=RZ@M4obsTy^z>}oG3`$ zf|LLki(*H7IM?{!g`p@bNm_4r0d_W`{R#q$Tj`GyGKP{UI#NTa zUt+3+(PkD=#RDRBlZW9X9nwHnGfw0+d(uM);BxyVwwG_Cngp(WQ?)*DwpmfSd zF2L6cWe;WWbtw`Tt44?ce~;o5u|4xQUWrs=9R6{>0>qgVKz3!KkFfY_WbyD7?O>?rpy5}oC& zXtet}Sl-6yS&xC^X~Gbw72r?ElR~CnYCaL@@9XuXN7B5>9Tym6bZDEK5iMS&8YE;y zEme0QYEg&K8xNvbcw}%8RG`5CD+Zk*-Bq!78H`<(3i8c#j1KCIEbB5D4U)%Y_qYrm zhA|qW!-^Em2D&^_)YB#D;DC}9GJ~kHb=qY!G66|ogKbD5hph#wtePBS0i=*Hg6|#E zs6JX2yKGZP#j?3$dgQiIhi3q`GuTtZjm(rzhm4Ebx>3aN3&RglLM;ZucB5{A^7E-e zb@`6ILvWzBeQCwfbcY0u$!=-Btmz!6HiMY4DvRHVSEC(}HTr#=-R;(pZ;6CO=vq}h z(ENwN@vxDUB%eiZu{v0|zWKV?li(%Et8{k~142()5Jh94H`n|C0@^RijKyXk#Wo(v zZy*W>L7~oeur`}p@^kK>MsoXs5J0p#Wi!g0(1GYQ+c>8pqcNv`&*$dhF>h7KE9y!Z zdI$Yl0)^23#Mefb$kOVI7v}5p&*VuiKBzQ4;|$mhy2z@f&G#ii>Glx=fnLRpC*mO+BMPh=dL)+idA%9l<)ShI(XzD&6zaeBP?@~uXKctiANha!!C2@hnwk`yQ{iV~|Y zzD#->Pv`5R2_Ya=@(^^-nzC1=Uk9S!!-#%i@WuM|1}6O&fOmoi0kIaq2nzvgV-eEr%R=ny;oP}w+jZmMxXZK^;QFC1%!JX2GA1Bb`(3fT~^6dPpe z3Q#0IZw}TnaIe1@;>1SD80;%F?<@CssX#g!btB@G;cQFS>V2iu#WzV^!5sb#*~@r{LAG zhY~IWY)qfJZ|Sm!zP)eHD-TX^ndP^Cb-jJ%uD$EKyVviXp0o!y@7z9-jg3z2+|`PZ z$y*Q-BoxOj{wC0&YKT_FuMmpjD|3u2`9$6)$7IH2%5%Knpi6mKT;OCkW?F$i`*0C@W_8(twM~Y`l20TGfvTZZJ>KarnWN6 z>2#RC;pRokkN6t^z2MjNB_kC$T7ZiZaCiJroY)?|HB1bLVId5MI$^03t`^{k4;CG( z97NFuSFt5ekJ{Uny{w*=||FJI$aCmXYJpn;d3iN)!b5X_YhEPoJj^ZM zzIV$KcHb-ajW2)b<=uC@HpGh_&d@iJ;wa)rAB{g01yF*j$%nwF>h}99@cuH4BNZhj z|oo9s=$dcaX=PVs;he?QMI-1d3EnuVrwLE;rKfZj094UAb#iC+?&>4&WfA+ov zuFdj({C#skNJ2 zX{~n}GbOy4r`3g8MUs*H8aBrgNm`-9m1Svm>5}oOLkq0yegziaOdNPfCeBz2rka7i z=O?XP>ZF_;r?a}%oR^<3Ms;1)_YhAIoQsGn3Cz9RP zT&JuKrdC_+l$mTdleuP+FiXuGv$@>1t(;t4PF9vLE$5WyBr8F_-j5mR~v<<0z+Sc-maG!+nw4KuQIVTd)3&D4(% zvShNkcJ;zs30H2aT%ccAS)tQZFI`<_U)@k3kx4R@pbs0w+B?D4b;O#QN}cv54SBI< zyM|M(Y1MFa8k>fr$sow9Ipi2;1BX-0adS9Q4#nYc(ga3nh6J5(Xh@YHj|slh2j5_} z14pKh{2s~es9nmw&u;POtTeEgT-;p}Lz327sox=@Dr_@Fj!LcI8i(5~;~G=BuYKTJ zY!pwIPc-)Dx&5KV)4QH^#J?A*`1$G5TU zLW$X1m`>m0Fk4&nl!#d>Urcyk%!2;nB{@e;whf#Np&sul;@@9Sg4#*mrFz5NtNbj z;5CyF_~+pj@g+6&t{gHcCnLv{!&M1f0&tQffxwufQRE1OIhx#DjV32sCKCuVvjy2g zIx}0534iD%CtIY+%~T|jLYY?1t|((C!;3&3=;RLK58hg!4=~}wU|b3N3S5IX6<91-IT&mcv84tn5?oB*F5Ms3wlpY&tiUEGVGla;;G;lM!Nz z#ZFUqx*l|Knq6eMi>xeNTFMcWlADEOL`WtHNnxprTXv`OK_};WCwZQeY;uxiPBP$_ zbZm8SD;?x|9jS9TOGJEX3q_*u<>QDcF;!MW5Gls8>gqC~u0+IBI7>xUMFldLtO|*A zMqKgd&-mkaD4N)9v4_v;c&Uy5C1lwixF=&|jD2D0p|erevQSuI#0en;ADSR>#w5B& zrpZVZi{E?FoL!J-RFD^oax$fYR7ukBpXb8TSC&IbNIQgcOV-RQrkSs2KFyoipJ~d= z&jMj{#Tn@egLe8C&<4my4tBy5t4dI9o=ZxI_XAA>(mP!I;~UdNtM(| z#F9F_(C-{~Uh3p_f$Sxu6KZL#Q=!#5v$C{W*gy#zis?pLl&cr&x6+FDu~8|!Ln=sL2luEtrVH_;2U=4xSMvo>2uN>tj+Zx}Pm z!43&sWO6W1G}yz)_=63kn#&{y58@XYW9zcS_O#jNw6ruC>?rWF5v)Tpt}>NtR%OTq zlI84Pn|&$*F+>@EuUssi$OaAp;*AKf$a7|Snw1I#o211T1;oFVVkd3Dzgo0V_spQTAruZ8ey@MogpB_ za;@TYO`s)uIS#c)?Q?=lru4`$md42krA?Vk zj?5`o7vLYUTXAn8pHD10dUEwqX0OO>71=!^+Eww;vL|7Wh?YP+Raxg6Q%-etxg}d; ztyt(%EEwo6zk(DKx z$tPF8c@SM3A#Kz4BhnY`Hgpi=n7ew$A4tk48}m}LOxZK{>K0_C2vjf`D)>96FVp0v zadkSrjLIhOw>m{BYQUb(*~?i1*b9kOHR;Lxq$IHNp(`iXUZygYnlh4XNve{SjqcTz>ZD9-`(!s)$WP6X?#S_O z4|bc(Wd>=MAwN~sP)nOD>(q)OS6$Djhlkb^F0cm`@Kr^utTC&rvZwgDQ)N~BvNC=Z zSGuUMFil!jMOLW^X&M1ZXmt=K()1{~f)J8*`;@@ZaWIgh?9)G)<0veg9Vo^!*TmTe zsqzGN@?-2I&#s;EHRRb#xrRs4@>gZFX69OT$`rFe<7{7;*HC5{Dy_)j=eI;#ix=lh zjfzZ5fh9YKHf9vobrnr03`)vh*@Xp3+>A*V?M6tfU3>%K8GaB2%Qw zkjZ7rykd3nVzUhVK@56r2L6SFglMRdrV_}-g*+}fB@rj{l94*oBq<`oMlK>b==0_1 zYVV4D1mb)(z7@;dc7fNU2OU!UeR23gsS*NK37KpFw|+Aex_D+Ud0`=MhUD;f&Y1h| zzrT^|vS4#0n9^wSIeB?fDHq~4mm|+4Qpr>#at?@`m=(DV z9McY#1aij)gzP8T5ErXyu2gyXa*oaMS#j<9UT4KwlZ)KT@?@E2Q+|fF(yHNSSlZ5A zelve--&rEMX-!4>KvQ9XUYQ|N$TABS8k0?Smo1k=;^(BfKyL|1OiMV3@){LEq)u5Z zB|5%-N~J62Lv+k1(#dobKnN`xK+tCQD0&QPw==d5l)|zmcAX@CYlP<@*{cJy$1)tl z;?WhgnHrD7vaBl4T))Ou>1$k|E?*wrzIkQS{*ZO;FO*5BC3$hJ8EhREjby3!mIywj6e*$h?Abk-;LkYvR@p8#g^q zFa9rri@i((GRr5*h^54`n!Gf!sHonoC;^3+^XuykEhQwMUridy39VhIFbiu5EvZF_ zg*a#+2g9Gaz}wQ}?63QwM~>)f95~qQRuex{PIN71kHQEOO{nZb#KArUcrDv$$;n;V z>2P#a>kW>w4D|faw793(xpJ{7%~*Y{Zb3#$PJTgJsi%xqh_q_CR9|8$X%ys?6!8o) zldZz2UsSA7TPw6wsYNf=I7%z@1q}|h*1j0_Zgj~SWxmlMGg~v0%8T+AQD*pyw6RREG3BPP9o(#l90o0v!n#g)stXR zui(~Z+?sqZArL|%YD*_b&Jx_#@{KkZKvm(k)>xZK#I}iHTr+NKi(^FB_!*3qFw(|( z16tc-BU57|bD^ySk6acXnI5Or0Ie=OvLiNaj)D1Z9k5&UR+_J-(-=qV6haiO~i(=W;#(rJ7ACNTId zvIYFXWk>AW_tDWra#9j3zj9p;S*dIG=X;pjO?b0E`%ydQ+tgJFU#HI7tE%+1zLt~M zZkv9JUT(~|ZFPZjNwP`GE4|eka=V$I7w4y`3#gfDB~_@F^10HqI+`vmZ4wGK>V}z$ zYQ0h<<}Gs67b?E~>_S~ZUY=7I;fW>kb*sth;_1kw!O*>W(NAX{U!E(LOOlO7pYbCeGpYMeJy~q} z`9Df4qVaUse40=5X+HfE>FMt!y*Z!e)4yT*yjRv+IG^Uze40=5X+F)T`81#A(|npw z^XZ>SD@zm7P4nsBD&0As=F@zdO{=n3tzLC>)vJBdzO267zBPSAePey+_D%I|?Yp|~ zhQ3?+?&{mu_ekGUeb2AH{;Vt3Oq^}-F7UqCKiYqC;M{@FeLmmc{1^EjA5;!LJERZJpQw>Z!IQM{6maAv;j0~APBSR+7$dJkN68bPQWb%v*nLHyyCeO%_$ulx!@~IMq`Xihd zCeSZJv=uUN#0m8hlrNOxJo=I;Ng;*va>%C_T5(>H&|ispWbzpa^;vib!Uy!E@UaQ z&>7gXklTq)fYkMW*+Yc*Z{AD(Q@rGxb}*}@I&29O&^HM3Kzba66kvHOg1=4-D+sXs zFp9CNFqQ>^inOyF_m8sJn=$`BOfQmjF;NaWwV%P_5zpg5#~9|2;dKy0jbh#PV);_o z)+ez%8UCYjoyK}o7(0dG`>|#FF=am7Z;aKw1;>IQ#xsU_V8#xxP+}B&G3+s{xl!m9 z#cgO*Kc*w5%hUA|Wk)bp68=WbV4Md_ygX|c-;~Z6F8g2%lCFid5#);uUP~O$=^O;G zJxpLZ4PdW1lguVqP6ODVMzH6^y!>?8hp+`q;@A~{Se0m}XNJzu@UO}?(F8OC-W z#hM$4+x?k1h++RGU6lzofH+}ziDG=QxERKsGl}g7{Z32}M+@(_a?04AcaBvtULRs# z8LpT-ia%p)6hhRlk9!*f2lYg;?z?Q1iP%9_GqccR9ui=s9L8}GMYRAcXDeRe#AXP@ z!wBYXHUiAGY75qk7w3Jf9egvIo$d){9Mut@6@(3vA4g+^V;zoJeymk5)FBBCL62CQ zjfJ`9v?CSWk_@FanFe)tP~i1GgjbdaSdTx4zhZ;V4lP59K*yO6dAl&{*0bucFfq- z7`qx_FoXaqGp7XOmf|I5=kn9z+4rD}xP-izcOSdOFrq8?+j0a^(LS;_blDDdG| zgmYFdYoW(L!rT%4j(E&Oejnx_Hse*yiQ-;>7{U?f%xi0A?H8M6X5|x`$!2Ag7*pns zir`qm=&GOP`^Y>zwzrh^U zG(jB&QM3bEmP4ryYU`jTA9{4M?fFeSz-ud%dvT1bCn#J-rKQln z6=0#U9%2Q?;Q?r!xMv53-2%1E@a|#zp%JxE(+y>mZ@^KI!Pg3-n4LlsJ0~)fc0oNA z=Ws4nO&DX0vKAp z*NO4FG0zOOt(b>;XlHowU56Nda+k$k2=xc^F%at3;pb=9uE5BNJZwJZ| z{03<4!ZSo0jI6_)c4FyySeYSin{jEDD@Iebm>VQjB!fE0x4>URTy`D!m7%300oq*I z_2Bl|UJQS3_O%vEt__zMZPemY7uFSOwXnMEzTWC(L|11V^S42^-bLBzVi(ml?5A2n z!BBK^%um&Z!egPZHyQ{9t(1FYgz5+k4M!tXhd<&EulM_`RBCFYzd!7spxVa#!7enW z**h5;k5VI{p}+t&5E`2dqcIc$Pdg|RdUIK*4)4g=FxBV{4ul5QLG6;zaFA*o_eBt< zuHit08cBpR7z$I11N|d`0q+RKVuU^+KuSeIi)I~K1 z2K>Q@zmkgh{gi*S-|zGJebfk3OZohffpB09F^F;a{84XUBx3CfjQS%~D`4peje3I! zmzRo$y*~e_H@uDt4Su_vVwDxA!|3o2jgNT4l({7^5Dp=77cBROBM7z2O4GPI^v0dr zJ6pQq(6LzS!rqBMaENLf90b~_BC0d$4UYIHfrxMbq-LR(2L__RLbEsQ^9Q3;ak;}D z$3#WO$Hqni!1-V(7`0Npp>b-|J4ua$45CQdsG5p~sDZHG8}(Z#Um!9D8n95_ppP00 z2cUHTx*)*b2sP#pj|QSq0M|c>B^{Gz6dFJ+;aFx6VX&ZgtdBT3W8si*d?0F}kR8ET z3mOwc5C~Ee!-0X}1j;4=X&^W-GVVi07o$ED9GRrdfd!0S6S@P0@1mA510>C`KN1at z+&~Gl2+;62tV%2aa{#bJ{i8_B;Q(Oug(iX{A+K+)^t_Cy!2E!n5TJtB@#q+svCog# zM16++BV%(V3a%BLWV<7U06>t>aG*Z`G+R?sk*N)aMn*!|09a95sD5t*XblD9rXEv* zc{my!tFYPpLF+_dU0}@b3wW)e@Q@9aY|wKJ>l6z>z1S=wh%f}^%wBvZ?|Yf;(TsY$ zg2cQw1UwO>IZYiC`}PzCi?j7&R33f@FLaYA_653)V9* z>UskFp5thpiTW6dK0uu}#%f)!?hEnvV- zggCTNqaj~l5WV}cK*q*_p~x_HSODEWj+{4wDp@N54sF1A#1GLMARt|{QaU54jKc#; z#%WkdV;HC)L(LHMO)iH+i70skOGbyRNCVfm#gXTH9b^Y64FLpj~YgLdb$@@^m6#EuN0r zMku-$H#IkP^;)R+}HnIsn_+)LP#GSUfGB z)-Eexg*wW!912utqr15oqjGlx^&ObL+P3!Ij;4mjE~>Guxy}QXi#BV z9t-8}XzD})sqbh5@JO66q74IqajhN(3KBInMCB&+&!3vl zpPK)hoth`?FTHqM`%V9x{ilD<4ioP&d0JjEZz->VR|T)-(Ax`<53SRgcr{G^ zoVpDG@ zr?a(!uJe=?+3RXciX64>^5Q(&z%ZmfBSW3c**?9I)F%p<6us~R z#gTWe|0=ENmpiV^U1E8A??rc1-m~`0HReH6=H@cl>w5|}J7_Tv z9$cEdswe67<-$$7{-ATCHS%!DNcz1KXXPBX z?fXHt>WbWRHFwSHrjN>mBEX5|IG4L|!j13Bd^NZhh)Q<^|*L=PH>-!3S{&dNGX>=FrpyRdB zOX;SY8*cV&u4Nww41`ClqcJ^M2STH^v2_7dYh$0rL~L<2A}wMif>Bzb3%w#q0Dg&o zVV&1ZFQFS_Wty{jA&X#QV&Y5){Ne9_9Hr%m-aH;MK^7!eaGF1Ik(G=cSXf*8iF?&2 ze(J45Z&>oy(G?fzisz>N*zesbC1X!Fh1 zf_2wVA4__@TA2ULEqmWHEG*gO4zH{I;rZ1WD_?u*rmNn*=W>Dev&CD;;okNe=o4T5 zp?-9W@T|!k;4a-p@`3XXXFtw;q5i{XZutCb zp61g_g{SnvTRt7CdL}>fU9;{4Z-;x}qqiSb{@}QLcggR|#=1h4AKl|gp_`ui#di7z z@!|E)e;&CmW8$T4kL`F)a4B_M`;(GS&N=YMD6#bNm*2Sh!&lFpx#FvP&fZe>#3Q=B z{ntPF%q9D-+WYFy3Sa5^y5x69&$;G3?WvE>S$Fti!TRVQrOO(B^$BsPvH7;-l6QMk zr!Tni(0eQ2zVyVayVG=6-uccYs;6FkVULDoCwRaRdX*(+N<%M2g+z?e~I5) z#jd!bDF8u?eKY3xCbKSRx zz%xdu&{zaVK`;0d6nX~+O=b=CY|JMf( zfApu5H>|pDjjYVPpYM_Xarfkw#|MA<`ePq*x(yFi8P0W&?)~BuV&#q-E>|B(x$c*{ z)phh;cVs;GXv3-#h0bkzwy$tK-KzeX{srmL-)xlLRr23^^@p|_@0{B9hkW%rgPN_? z)>A#)mZyT7Znl4T|9!Uh<*SqSDSvQCGw@JE{PwHonbHb&dVXx*RK2shr)k2lb!MOR zxy#=bD3?7`xUzUv`P!X7xqZvJo#xOd2lsyPghzFxzjf+?uIz>@cil0%KbZgQ7x}tF z$EmwS`#yP5w0p;&*8V84>6X&hMyZ)ge|_rdhp#V9nW<7dwM%i={>?`|-uTo{yN$J( z4>VqS?&e>9`SOnzW&KvM_0L!A88&ViuDt8H4Xvg>3v!zWrf+zUa)E3J&)h^==sNX zF2DV}6^GLs`d`ld_~gQa#iGw`)xJAip|jf;Jyds9+s&eFzqn|{-=7=0%=`LHyAB@u z!QoKDTl=j$j_>>X&*{;R);9g*{hjL%Js~(aQ+Z-<#FcdK@?%-QdhEoG7cNtOx?wHZ z_F&Fb}fsdIhJ{SrG|tA&j!+1KCzx(vFfiba`YHE#bT||aL?=;jr(Lv@b`;x79CSGZcY84| zI%rh<-&;ceqxin(mXUq`r?HfX8)pVZvNM_NOP1`rvWpl}V@N3? zVJumu5@n~NY$0V!_E2`RWDAw8`bJ8=-oEea{o}jdzs~iX=X##!oafy4{kxZQ&hTr^ zIMK6iumhu2W8)3U27LPYZG$Yj{4@(4*&W*XegHS!JF*_*1SV8Gn`&G^k|iKG0CJlS zu9}M^qxnQloU|0#%GDype=%)&)rDPrD|qS>$Ly5uEqpDXQFHW$#!HHJ>%#UTRpOiL zcigYLyb^wcG%AW{pA<&Igz_TvjvG=<5Q%S|ij4(4FRqvaX&dKyai!BdxN~cr3@hYi zMjnRuHDWU~LC20d(+P<<=inx~NJEd^To2EtJIY9b%?O`A9{2^E!l6%o0YnQR=gWrp zkQGlQj57*316AMwt*OKEVb}3?kOv(5inl(cmVn!MwT!>6lhoI7Lx0%!T*8iI12nr9 zhXf!Z9QN4r-+GtZ=9`0tn3y1c5dbYI#djt!fw#|qfY78J+Z-ATghpc+sq?V5$|gd% zNdbl}(NRjH1oMg8c!%3||9A1k(xUU3@y9Z=^0iN!t&%aooB;hDD4+lWhceYNRU(xB z+%Mkk_9d*iZOXp^!FUf4Gyx=_z6S`hf5#`@z7MsXS^v-=Yb#P2MtegI3l!tw}DWpZ0n?XN$Fi3gKw4%@~g?*F*9FbQM zxO}C3P9D54Rvkl0(ip8dwq(RKsFxczF%^BP2UGhtewkGA0@2JhQGQMFPL>wn{gjN{SjSrk0r=nWbtOIh%{*ct9_FKrw12y3Hn^T;-y;Y zV zm#1BAMr@{HRkKyRyAOfU78KRww>dFuvUq1GIj#AyS5>HxA1U3!8J?O{Y^{*-!{Sp4 zk@z!q_dW3pcWUk(U848;d>P?h@@3edF^aFrxhjq0BE2I-L88z+rhI~D@>%h| z?$U-YEjui~&+{IZR!92r@t>CS@+G)t6dB04=xFgVCz>b zj<0oLlBK)vbNLW@NN$%LemuHLP3fTFw?>f)f zah)0!My1(Y8WXVU;?YtxeEPstiJxexBgjIshi}~5ipBsLP`U;ht;~a9ZC*cNDy?Gc zKCU$-ho_n0LshbrtDn`FsIB)68u6XtAFe?rH!Rk%o4yW;dM`!ovU8rm z&Y9of?2krs+V*-A>b$-Fav^!tAAGv}}KyUu$* z&vjq-b>GkX$2sreU5T>&av?V&>O*&Yy3qrJ5!tJI zE?HdKDK+S)5-xpcdy9Q^!RSI2GV=5iiLoX7J3I^OC|-N(U<37rkpk?DYPl9}+Af z&AXrQvVWe(k=vD%FPa;kh2^%7JUZ|&rsM59fm=@3(@lwKuY#kJ8^wk@TjSAGg^u=t zftoZK^Q<*N@!NNr%f2rg%_^b4{L0*y|MDPV)%hIb{mc)WK{_EH4YxGl9%ICMn|Ub{waZf7cl*55d-}#RnT;Q5wPHYRdfv)Aq+4Qdam5Ux?rhJs-{x z;zv6kcU$Y)%@xP4aj@&&j25c)EiR4>aURL7t^K0(@5jrvTg?JLc_Np}C-t?ZEL$>V zMER|*7DGO{_OscxHpfnYmd8Zl2CGdU4df$nS14*?kkuYc{cVjZ_h^eA2(@N5ma|7 zHIkm_{UK|>D4qRt(uSlv6VJCtSnZCBb5#F%AjRf%VcU6o z>(|e8*JsB}?owJYdD`m1ZvUm*yefZdcpTAI)2JucXD}4j^U%QM{dI%%!8+$R#nzYf zaxrD~5T~y1qyw6t$c*}g-rM5VH0OT$hk%RxPXW$uJKUJuE-SyE?jHXA;S$guDHoIPa@ibbw62oD9Q z4D$8GBMAZ{c&@y!7)tQ|{*>qY6+>wf!U&Y&Vi-xHFvidjMp1Aq%^=Wq42E$r9AyB6 z;TVjO6dcQ-I5(C}9^yyftRfr@2nuB&j6tCF7?OhWU?>t=k6}nIXB0>O0RseAD-_3Z zZf*-g>!XAX&TOhW*BHa28F!AIt_#JU@($fUzEXVhzmm^kS+`bpxhXmfVnVG zJph6*+}r>HpdJJWhH>`*2oCinKnRF4Kqv&>g9hBX0hS7vGe8lF>kWXSkPldw0o!hL1Armingf7i5NE)SO(>RxdI10$ znh^jn0NMjX2xt!sg?)el2GRw?VNEdthw6rrQ11hbhI7Lh$h!bXV6WlqQNq;#M=5Au z9KiL!*+CA)5+vjyoPxc^jxb1j0;M7S2$l?Y4}yed7(0cac>qWRItKxX!o5YZD0dI? zA9|3)X~?G}JR<-}LT3OVDd_wKB!fcvQV0g=$BrCmUy3yrmotUokgq5TI)4Ghz+7ky zt}jh6P>pCBhqy2ZTsH>5{lT_0Ma4{wo}r=!E;oA|o=@mMW*);zwopI;!4$q2^t`}I<5e?xCot{8CPc^$<_vO7SasO mP>d5Rj!5XL>&Vm9mDwNYBo6%h_74SYLs2p+Dn^!DW&Q-DOhVZJ literal 0 HcmV?d00001 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..cad474a1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [v1.0.0] - 2026-03-28 + +### Initial Release + +The first open-source release of **Lark CLI** — the official command-line interface for [Lark/Feishu](https://www.larksuite.com/). + +### Features + +#### Core Commands + +- **`lark api`** — Make arbitrary Lark Open API calls directly from the terminal with flexible parameter support. +- **`lark auth`** — Complete OAuth authentication flow, including interactive login, logout, token status, and scope management. +- **`lark config`** — Manage CLI configuration, including `init` for guided setup and `default-as` for switching contexts. +- **`lark schema`** — Inspect available API services and resource schemas. +- **`lark doctor`** — Run diagnostic checks on CLI configuration and environment. +- **`lark completion`** — Generate shell completion scripts for Bash, Zsh, Fish, and PowerShell. + +#### Service Shortcuts + +Built-in shortcuts for commonly used Lark APIs, enabling concise commands like `lark im send` or `lark drive upload`: + +- **IM (Messaging)** — Send messages, manage chats, and more. +- **Drive** — Upload, download, and manage cloud documents. +- **Docs** — Work with Lark documents. +- **Sheets** — Interact with spreadsheets. +- **Base (Bitable)** — Manage multi-dimensional tables. +- **Calendar** — Create and manage calendar events. +- **Mail** — Send and manage emails. +- **Contact** — Look up users and departments. +- **Task** — Create and manage tasks. +- **Event** — Subscribe to and manage event callbacks. +- **VC (Video Conference)** — Manage meetings. +- **Whiteboard** — Interact with whiteboards. + +#### AI Agent Skills + +Bundled AI agent skills for intelligent assistance: + +- `lark-im`, `lark-doc`, `lark-drive`, `lark-sheets`, `lark-base`, `lark-calendar`, `lark-mail`, `lark-contact`, `lark-task`, `lark-event`, `lark-vc`, `lark-whiteboard`, `lark-wiki`, `lark-minutes` +- `lark-openapi-explorer` — Explore and discover Lark APIs interactively. +- `lark-skill-maker` — Create custom AI skills. +- `lark-workflow-meeting-summary` — Automated meeting summary workflow. +- `lark-workflow-standup-report` — Automated standup report workflow. +- `lark-shared` — Shared skill utilities. + +#### Developer Experience + +- Cross-platform support (macOS, Linux, Windows) via GoReleaser. +- Shell completion for Bash, Zsh, Fish, and PowerShell. +- Bilingual documentation (English & Chinese). +- CI/CD pipelines: linting, testing, coverage reporting, and automated releases. + +[v1.0.0]: https://github.com/larksuite/cli/releases/tag/v1.0.0 diff --git a/CLA.md b/CLA.md new file mode 100644 index 00000000..4f186c6b --- /dev/null +++ b/CLA.md @@ -0,0 +1,28 @@ +> Thank you for your interest in open source projects hosted or managed by ByteDance Ltd. and/or its Affiliates ("**ByteDance**") . In order to clarify the intellectual property license granted with Contributions from any person or entity, ByteDance must have a Contributor License Agreement ("**CLA**") on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of ByteDance and its users; it does not change your rights to use your own Contributions for any other purpose. +> If you are an individual making a submission on your own behalf, you should accept the Individual Contributor License Agreement. If you are making a submission on behalf of a legal entity (the “**Corporation**”), you should sign the separation Corporate Contributor License Agreement. + +**ByteDance Individual Contributor License Agreement v1.** **1** +By clicking “Accept” on this page, You accept and agree to the following terms and conditions for Your present and future Contributions submitted to ByteDance. Except for the license granted herein to ByteDance and recipients of software distributed by ByteDance, You reserve all right, title, and interest in and to Your Contributions. +1.Definitions. +"Affiliate" shall mean an entity that Controls, is Controlled by, or is under common Control with You or ByteDance, respectively (but only as long as such Control exists). +"Control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. +"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to ByteDance for inclusion in, or documentation of, any of the products owned or managed by ByteDance (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to ByteDance or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, ByteDance for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." +"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with ByteDance. For the avoidance of doubt, the Corporation making a Contribution and all of its Affiliates are considered to be a single Contributor and this CLA shall apply to Contributions Submitted by the Corporation or any of its Affiliates. +2.Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to ByteDance and to recipients of software distributed by ByteDance a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. +3.Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to ByteDance and to recipients of software distributed by ByteDance a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. +4.You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to ByteDance, or that your employer has executed a separate Corporate CLA with ByteDance. +5.You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. +6.You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. +7.Should You wish to submit work that is not Your original creation, You may submit it to ByteDance separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". +8.You agree to notify ByteDance of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. +9.You agree that contributions to Projects and information about contributions may be maintained indefinitely and disclosed publicly, including Your name and other information that You submit with your submission. +10.This Agreement is the entire agreement and understanding between the parties, and supersedes any and all prior agreements, understandings or communications, written or oral, between the parties relating to the subject matter hereof. This Agreement may be assigned by ByteDance. + +[ByteDance Corporate Contributor License Agreement v1.1](./ByteDance_Corporate_Contributor_License_Agreement_v1.1.pdf) + +This version of the Contributor License Agreement allows a legal entity (the “Corporation”) to submit Contributions to the applicable project. +ByteDance Corporate Contributor License Agreement v1.1.pdf +A person authorized to sign legal documents on behalf of your employer (usually a VP or higher) must sign the Contributor Agreement on behalf of the employer. +If you have not already signed this agreement, please complete and sign, then scan and email a pdf file of this Agreement to opensource-cla@bytedance.com. Please read this document carefully before signing and keep a copy for your records. + +If you need to update your CLA, please email  from the email address associated with your individual or corporate information. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..2b8bbb5e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lark Technologies Pte. Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..7d78c510 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT + +BINARY := lark-cli +MODULE := github.com/larksuite/cli +VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +DATE := $(shell date +%Y-%m-%d) +LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE) +PREFIX ?= /usr/local + +.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta + +fetch_meta: + python3 scripts/fetch_meta.py + +build: fetch_meta + go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) . + +vet: fetch_meta + go vet ./... + +unit-test: fetch_meta + go test -race -gcflags="all=-N -l" -count=1 ./cmd/... ./internal/... ./shortcuts/... + +integration-test: build + go test -v -count=1 ./tests/... + +test: vet unit-test integration-test + +install: build + install -d $(PREFIX)/bin + install -m755 $(BINARY) $(PREFIX)/bin/$(BINARY) + @echo "OK: $(PREFIX)/bin/$(BINARY) ($(VERSION))" + +uninstall: + rm -f $(PREFIX)/bin/$(BINARY) + +clean: + rm -f $(BINARY) diff --git a/README.md b/README.md new file mode 100644 index 00000000..d431d69b --- /dev/null +++ b/README.md @@ -0,0 +1,268 @@ +# lark-cli + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Go Version](https://img.shields.io/badge/go-%3E%3D1.23-blue.svg)](https://go.dev/) + +[中文版](./README.zh.md) | [English](./README.md) + +A command-line tool for [Lark/Feishu](https://www.larksuite.com/) Open Platform — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 19 AI Agent [Skills](./skills/). + +[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing) + +## Why lark-cli? + +- **Agent-Native Design** — 19 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup +- **Wide Coverage** — 11 business domains, 200+ curated commands, 19 AI Agent [Skills](./skills/) +- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates +- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install` +- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps +- **Secure & Controllable** — Input injection protection, terminal output sanitization, OS-native keychain credential storage +- **Three-Layer Architecture** — Shortcuts (human & AI friendly) → API Commands (platform-synced) → Raw API (full coverage), choose the right granularity + +## Features + +| Category | Capabilities | +| ------------- | ----------------------------------------------------------------------------------- | +| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions | +| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media | +| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards | +| 📁 Drive | Upload and download files, search docs & wiki, manage comments | +| 📊 Base | Create and manage tables, fields, records, views, dashboards, data aggregation & analytics | +| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data | +| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders | +| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents | +| 👤 Contact | Search users by name/email/phone, get user profiles | +| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail | +| 🎥 Meetings | Search meeting records, query meeting minutes & recordings | + +## Installation & Quick Start + +### Requirements + +Before you start, make sure you have: + +- Node.js (`npm`/`npx`) +- Go `v1.23`+ and Python 3 (only required for building from source) + +### Quick Start (Human Users) + +> **Tip:** If you have an AI Agent, you can hand this README to it and let the AI handle installation and setup — jump to [Quick Start (AI Agent)](#quick-start-ai-agent). + +#### Install CLI + +**From npm (recommended):** + +```bash +npm install -g @larksuite/cli +``` + +**From source:** + +```bash +make install +``` + +#### Install AI Agent Skills + +[Skills](./skills/) are structured instruction documents that enable AI Agents to use this CLI: + +```bash +# Install all skills to current directory +npx skills add larksuite/cli -y + +# Install all skills globally +npx skills add larksuite/cli -y -g +``` + +#### Configure & Use + +```bash +# 1. Configure app credentials (one-time, interactive guided setup) +lark-cli config init + +# 2. Log in (--recommend auto-selects commonly used scopes) +lark-cli auth login --recommend + +# 3. Start using +lark-cli calendar +agenda +``` + +## Quick Start (AI Agent) + +> The following steps are for AI Agents. Some steps require the user to complete actions in a browser. + +```bash +# 1. Install CLI +npm install -g @larksuite/cli + +# 2. Install Skills (enables AI Agent to use this CLI) +npx skills add larksuite/cli --all -y + +# 3. Configure app credentials +# Important: run this command in the background. It will output an authorization URL — extract it and send it to the user. The command exits automatically after the user completes the setup in browser. +lark-cli config init --new + +# 4. Login +# Same as above: run in the background, extract the authorization URL and send it to the user. +lark-cli auth login --recommend + +# 5. Verify +lark-cli auth status +``` + +## Agent Skills + +| Skill | Description | +| ------------------------------- | ------------------------------------------------------------------------------------- | +| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) | +| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions | +| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions | +| `lark-doc` | Create, read, update, search documents (Markdown-based) | +| `lark-drive` | Upload, download files, manage permissions & comments | +| `lark-sheets` | Create, read, write, append, find, export spreadsheets | +| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics | +| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment | +| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail | +| `lark-contact` | Search users by name/email/phone, get user profiles | +| `lark-wiki` | Knowledge spaces, nodes, documents | +| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format | +| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) | +| `lark-whiteboard` | Whiteboard/chart DSL rendering | +| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) | +| `lark-openapi-explorer` | Explore underlying APIs from official docs | +| `lark-skill-maker` | Custom skill creation framework | +| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report | +| `lark-workflow-standup-report` | Workflow: agenda & todo summary | + +## Authentication + +| Command | Description | +| ------------- | -------------------------------------------------------------- | +| `auth login` | OAuth login with interactive selection or CLI flags for scopes | +| `auth logout` | Sign out and remove stored credentials | +| `auth status` | Show current login status and granted scopes | +| `auth check` | Verify a specific scope (exit 0 = ok, 1 = missing) | +| `auth scopes` | List all available scopes for the app | +| `auth list` | List all authenticated users | + +```bash +# Interactive login (TUI guides domain and permission level selection) +lark-cli auth login + +# Filter by domain +lark-cli auth login --domain calendar,task + +# Recommended auto-approval scopes +lark-cli auth login --recommend + +# Exact scope +lark-cli auth login --scope "calendar:calendar:readonly" + +# Agent mode: return verification URL immediately, non-blocking +lark-cli auth login --domain calendar --no-wait +# Resume polling later +lark-cli auth login --device-code + +# Identity switching: execute commands as user or bot +lark-cli calendar +agenda --as user +lark-cli im +messages-send --as bot --chat-id "oc_xxx" --text "Hello" +``` + +## Three-Layer Command System + +The CLI provides three levels of granularity, covering everything from quick operations to fully custom API calls: + +### 1. Shortcuts + +Prefixed with `+`, designed to be friendly for both humans and AI, with smart defaults, table output, and dry-run previews. + +```bash +lark-cli calendar +agenda +lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello" +lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X" +``` + +Run `lark-cli --help` to see all shortcut commands. + +### 2. API Commands + +Auto-generated from Lark OAPI metadata, curated through evaluation and quality gates — 100+ commands mapped 1:1 to platform endpoints. + +```bash +lark-cli calendar calendars list +lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}' +``` + +### 3. Raw API Calls + +Call any Lark Open Platform endpoint directly, covering 2500+ APIs. + +```bash +lark-cli api GET /open-apis/calendar/v4/calendars +lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}' +``` + +## Advanced Usage + +### Output Formats + +```bash +--format json # Full JSON response (default) +--format pretty # Human-friendly formatted output +--format table # Readable table +--format ndjson # Newline-delimited JSON (for piping) +--format csv # Comma-separated values +``` + +### Pagination + +```bash +--page-all # Auto-paginate through all pages +--page-limit 5 # Max 5 pages +--page-delay 500 # 500ms between page requests +``` + +### Dry Run + +For commands that may have side effects, preview the request with --dry-run first: + +```bash +lark-cli im +messages-send --chat-id oc_xxx --text "hello" --dry-run +``` + +### Schema Introspection + +Use schema to inspect any API method's parameters, request body, response structure, supported identities, and scopes: + +```bash +lark-cli schema +lark-cli schema calendar.events.instance_view +lark-cli schema im.messages.delete +``` + +## Security & Risk Warnings (Read Before Use) + +This tool can be invoked by AI Agents to automate operations on the Lark/Feishu Open Platform, and carries inherent risks such as model hallucinations, unpredictable execution, and prompt injection. After you authorize Lark/Feishu permissions, the AI Agent will act under your user identity within the authorized scope, which may lead to high-risk consequences such as leakage of sensitive data or unauthorized operations. Please use with caution. + +To reduce these risks, the tool enables default security protections at multiple layers. However, these risks still exist. We strongly recommend that you do not proactively modify any default security settings; once relevant restrictions are relaxed, the risks will increase significantly, and you will bear the consequences. + +We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage. + +Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities. + +## Contributing + +Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls). + +For major changes, we recommend discussing with us first via an Issue. + +## License + +This project is licensed under the **MIT License**. +When running, it calls Lark/Feishu Open Platform APIs. To use these APIs, you must comply with the following agreements and privacy policies: + +- [Feishu User Terms of Service](https://www.feishu.cn/terms) +- [Feishu Privacy Policy](https://www.feishu.cn/privacy) +- [Feishu Open Platform App Service Provider Security Management Specifications](https://open.feishu.cn/document/uAjLw4CM/uMzNwEjLzcDMx4yM3ATM/management-practice/app-service-provider-security-management-specifications) +- [Lark User Terms of Service](https://www.larksuite.com/user-terms-of-service) +- [Lark Privacy Policy](https://www.larksuite.com/privacy-policy) diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 00000000..52a78c3b --- /dev/null +++ b/README.zh.md @@ -0,0 +1,269 @@ +# lark-cli + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Go Version](https://img.shields.io/badge/go-%3E%3D1.23-blue.svg)](https://go.dev/) + +[中文版](./README.zh.md) | [English](./README.md) + +飞书/Lark 开放平台命令行工具 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 19 个 AI Agent [Skills](./skills/)。 + +[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献) + +## 为什么选 lark-cli? + +- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书 +- **覆盖面广** — 11 大业务域、200+ 精选命令、 19 个 AI Agent [Skills](./skills/) +- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率 +- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用 +- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步 +- **安全可控** — 输入防注入、终端输出净化、OS 原生密钥链存储凭证 +- **三层调用架构** — 快捷命令(人机友好)→ API 命令(平台同步)→ 通用调用(全 API 覆盖),按需选择粒度 + +## 功能 + +| 类别 | 能力 | +| ------------- | --------------------------------------------------------------------------- | +| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 | +| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 | +| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 | +| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 | +| 📊 多维表格 | 创建和管理多维表格、字段、记录、视图、仪表盘,数据聚合分析 | +| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 | +| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 | +| 📚 知识库 | 创建和管理知识空间、节点和文档 | +| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 | +| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 | +| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 | + +## 安装与快速开始 + +### 环境要求 + +开始之前,请确保具备以下条件: + +- Node.js(`npm`/`npx`) +- Go `v1.23`+ 和 Python 3(仅源码构建需要) + +### 快速开始(人类用户) + +> **Tip:** 如果你拥有 AI Agent,可以直接把本 README 丢给它,让 AI 帮你完成安装和配置 — 跳转到[快速开始(AI Agent)](#快速开始ai-agent)查看。 + +#### 安装 CLI + +**从 npm 安装(推荐):** + +```bash +npm install -g @larksuite/cli +``` + +**从源码安装:** + +```bash +make install +``` + +#### 安装 AI Agent Skills + +[Skills](./skills/) 是结构化的指令文档,使 AI Agent 能够使用本 CLI: + +```bash +# 安装所有 skills 到当前目录 +npx skills add larksuite/cli -y + +# 安装所有 skills 到全局 +npx skills add larksuite/cli -y -g +``` + +#### 配置与使用 + +```bash +# 1. 配置应用凭证(仅需一次,交互式引导完成) +lark-cli config init + +# 2. 登录授权(--recommend 自动选择常用权限) +lark-cli auth login --recommend + +# 3. 开始使用 +lark-cli calendar +agenda +``` + +### 快速开始(AI Agent) + +> 以下步骤面向 AI Agent,部分步骤需要用户在浏览器中配合完成。 + +```bash +# 1. 安装 CLI +npm install -g @larksuite/cli + +# 2. 安装 Skills(使 AI Agent 能够使用本 CLI) +npx skills add larksuite/cli --all -y + +# 3. 配置应用凭证 +# 重要:在后台运行此命令,命令会输出一个授权链接,提取该链接并发送给用户,用户在浏览器中完成配置后命令会自动退出。 +lark-cli config init --new + +# 4. 登录 +# 同上,后台运行,提取授权链接发给用户 +lark-cli auth login --recommend + +# 5. 验证 +lark-cli auth status +``` + + +## Agent Skills + +| Skill | 说明 | +| --------------------------------- | ----------------------------------------------------------------------------- | +| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) | +| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 | +| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 | +| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) | +| `lark-drive` | 上传、下载文件,管理权限与评论 | +| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 | +| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 | +| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 | +| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 | +| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 | +| `lark-wiki` | 知识空间、节点、文档 | +| `lark-event` | 实时事件订阅(WebSocket),支持正则路由与 Agent 友好格式 | +| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) | +| `lark-whiteboard` | 画板/图表 DSL 渲染 | +| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) | +| `lark-openapi-explorer` | 从官方文档探索底层 API | +| `lark-skill-maker` | 自定义 skill 创建框架 | +| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 | +| `lark-workflow-standup-report` | 工作流:日程待办摘要 | + +## 认证 + +| 命令 | 说明 | +| --------------- | -------------------------------------------------- | +| `auth login` | OAuth 登录,支持交互式选择或命令行参数指定 scope | +| `auth logout` | 登出并删除已存储的凭证 | +| `auth status` | 查看当前登录状态和已授权的 scope | +| `auth check` | 校验指定 scope(exit 0 = 有权限,1 = 缺失) | +| `auth scopes` | 列出应用的所有可用 scope | +| `auth list` | 列出所有已认证的用户 | + +```bash +# 交互式登录(TUI 引导选择业务域和权限级别) +lark-cli auth login + +# 按域筛选 +lark-cli auth login --domain calendar,task + +# 推荐的自动审批 scopes +lark-cli auth login --recommend + +# 精确 scope +lark-cli auth login --scope "calendar:calendar:readonly" + +# Agent 模式:立即返回验证 URL,不阻塞 +lark-cli auth login --domain calendar --no-wait +# 稍后恢复轮询 +lark-cli auth login --device-code + +# 身份切换:以用户或机器人身份执行命令 +lark-cli calendar +agenda --as user +lark-cli im +messages-send --as bot --chat-id "oc_xxx" --text "Hello" +``` + +## 三层命令调用 + +CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义的全部场景: + +### 1. 快捷命令(Shortcuts) + +以 `+` 为前缀,对人类与 AI 友好化封装,内置智能默认值、表格输出和 dry-run 预览。 + +```bash +lark-cli calendar +agenda +lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello" +lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能" +``` + +运行 `lark-cli --help` 查看所有快捷命令。 + +### 2. API 命令 + +从飞书 OAPI 元数据自动生成,经过评测与准入筛选,100+ 精选命令与平台端点一一对应。 + +```bash +lark-cli calendar calendars list +lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}' +``` + +### 3. 通用 API 调用 + +直接调用任意飞书开放平台端点,覆盖 2500+ API。 + +```bash +lark-cli api GET /open-apis/calendar/v4/calendars +lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}' +``` + +## 进阶用法 + +### 输出格式 + +```bash +--format json # 完整 JSON 响应(默认) +--format pretty # 人性化格式输出 +--format table # 易读表格 +--format ndjson # 换行分隔 JSON(适合管道处理) +--format csv # 逗号分隔值 +``` + +### 分页 + +```bash +--page-all # 自动翻页获取所有数据 +--page-limit 5 # 最多获取 5 页 +--page-delay 500 # 每页请求间隔 500ms +``` + +### Dry Run + +对可能产生副作用的命令,建议先用 --dry-run 预览请求: + +```bash +lark-cli im +messages-send --chat-id oc_xxx --text "hello" --dry-run +``` + +### Schema 自省 + +使用 schema 查看任意 API 方法的参数、请求体、响应结构、支持身份和 scopes: + +```bash +lark-cli schema +lark-cli schema calendar.events.instance_view +lark-cli schema im.messages.delete +``` + +## 安全与风险提示(使用前必读) + +本工具可供 AI Agent 调用以自动化操作飞书/Lark 开放平台,存在模型幻觉、执行不可控、提示词注入等固有风险;授权飞书权限后,AI Agent 将以您的用户身份在授权范围内执行操作,可能导致敏感数据泄露、越权操作等高风险后果,请您谨慎操作和使用。 + +为降低上述风险,工具已在多个层面启用默认安全保护,但上述风险仍然存在。我们强烈建议不要主动修改任何默认安全配置;一旦放开相关限制,上述风险将显著提高,由此产生的后果需由您自行承担。 + +我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。 + +请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。 + +## 贡献 + +欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。 + +对于较大的改动,建议先通过 Issue 与我们讨论。 + +## 许可证 + +本项目基于 **MIT 许可证** 开源。 +该软件运行时会调用 Lark/飞书开放平台的 API,使用这些 API 需要遵守如下协议和隐私政策: + +- [飞书用户服务协议](https://www.feishu.cn/terms) +- [飞书隐私政策](https://www.feishu.cn/privacy) +- [飞书开放平台独立软件服务商安全管理运营规范](https://open.feishu.cn/document/uAjLw4CM/uMzNwEjLzcDMx4yM3ATM/management-practice/app-service-provider-security-management-specifications) +- [Lark User Terms of Service](https://www.larksuite.com/user-terms-of-service) +- [Lark Privacy Policy](https://www.larksuite.com/privacy-policy) diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..0f0b8463 --- /dev/null +++ b/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +set -euo pipefail +cd "$(dirname "$0")" +python3 scripts/fetch_meta.py +VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev) +go build -ldflags "-s -w -X github.com/larksuite/cli/internal/build.Version=${VERSION} -X github.com/larksuite/cli/internal/build.Date=$(date +%Y-%m-%d)" -o lark-cli . +echo "OK: ./lark-cli (${VERSION})" diff --git a/cmd/api/api.go b/cmd/api/api.go new file mode 100644 index 00000000..d2ec7098 --- /dev/null +++ b/cmd/api/api.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "regexp" + "strings" + + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/spf13/cobra" +) + +// APIOptions holds all inputs for the api command. +type APIOptions struct { + Factory *cmdutil.Factory + Cmd *cobra.Command + Ctx context.Context + + // Positional args + Method string + Path string + + // Flags + Params string + Data string + As core.Identity + Output string + PageAll bool + PageSize int + PageLimit int + PageDelay int + Format string + DryRun bool +} + +func parseJsonOpt(input, label string) (map[string]interface{}, error) { + if input == "" { + return nil, nil + } + var result map[string]interface{} + if err := json.Unmarshal([]byte(input), &result); err != nil { + return nil, output.ErrValidation("%s invalid format, expected JSON object", label) + } + return result, nil +} + +var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`) + +func normalisePath(raw string) string { + if matches := urlPrefixRe.FindStringSubmatch(raw); len(matches) > 1 { + raw = matches[1] + } else if !strings.HasPrefix(raw, "/open-apis/") { + raw = "/open-apis/" + strings.TrimPrefix(raw, "/") + } + return validate.StripQueryFragment(raw) +} + +// NewCmdApi creates the api command. If runF is non-nil it is called instead of apiRun (test hook). +func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command { + opts := &APIOptions{Factory: f} + var asStr string + + cmd := &cobra.Command{ + Use: "api ", + Short: "Generic Lark API requests", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Method = strings.ToUpper(args[0]) + opts.Path = args[1] + opts.Cmd = cmd + opts.Ctx = cmd.Context() + opts.As = core.Identity(asStr) + if runF != nil { + return runF(opts) + } + return apiRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON") + cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON") + cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)") + cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses") + cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages") + cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)") + cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") + cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") + cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") + + cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp + } + _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp + }) + _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp + }) + + return cmd +} + +// buildAPIRequest validates flags and builds a RawApiRequest. +func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) { + params, err := parseJsonOpt(opts.Params, "--params") + if err != nil { + return client.RawApiRequest{}, err + } + if params == nil { + params = map[string]interface{}{} + } + var data interface{} + if opts.Data != "" { + data, err = parseJsonOpt(opts.Data, "--data") + if err != nil { + return client.RawApiRequest{}, err + } + } + if opts.PageSize > 0 { + params["page_size"] = opts.PageSize + } + + request := client.RawApiRequest{ + Method: opts.Method, + URL: normalisePath(opts.Path), + Params: params, + Data: data, + As: opts.As, + } + // WithFileDownload tells the SDK to skip CodeError parsing on 200 OK. + if opts.Output != "" { + request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload()) + } + return request, nil +} + +func apiRun(opts *APIOptions) error { + f := opts.Factory + opts.As = f.ResolveAs(opts.Cmd, opts.As) + + if opts.PageAll && opts.Output != "" { + return output.ErrValidation("--output and --page-all are mutually exclusive") + } + + request, err := buildAPIRequest(opts) + if err != nil { + return err + } + + config, err := f.ResolveConfig(opts.As) + if err != nil { + return err + } + + if opts.DryRun { + return apiDryRun(f, request, config, opts.Format) + } + // Identity info is now included in the JSON envelope; skip stderr printing. + // cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected) + + ac, err := f.NewAPIClientWithConfig(config) + if err != nil { + return err + } + + out := f.IOStreams.Out + format, formatOK := output.ParseFormat(opts.Format) + if !formatOK { + fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format) + } + + if opts.PageAll { + return apiPaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut, + client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}) + } + + resp, err := ac.DoAPI(opts.Ctx, request) + if err != nil { + return output.MarkRaw(output.ErrNetwork("API call failed: %v", err)) + } + err = client.HandleResponse(resp, client.ResponseOptions{ + OutputPath: opts.Output, + Format: format, + Out: out, + ErrOut: f.IOStreams.ErrOut, + }) + // MarkRaw tells root error handler that the API response was already written + // to stdout, so it should skip the stderr error envelope. Only apply when + // HandleResponse actually wrote output (i.e. returned a business/API error + // after printing JSON to stdout). Non-JSON HTTP errors (e.g. 404 text/plain) + // produce no stdout output and need the envelope. + if err != nil && client.IsJSONContentType(resp.Header.Get("Content-Type")) { + return output.MarkRaw(err) + } + return err +} + +func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error { + return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format) +} + +func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions) error { + switch format { + case output.FormatNDJSON, output.FormatTable, output.FormatCSV: + pf := output.NewPaginatedFormatter(out, format) + result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) { + pf.FormatPage(items) + }, pagOpts) + if err != nil { + return output.MarkRaw(output.ErrNetwork("API call failed: %v", err)) + } + if apiErr := client.CheckLarkResponse(result); apiErr != nil { + output.FormatValue(out, result, output.FormatJSON) + return output.MarkRaw(apiErr) + } + if !hasItems { + fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format) + output.FormatValue(out, result, output.FormatJSON) + } + return nil + default: + result, err := ac.PaginateAll(ctx, request, pagOpts) + if err != nil { + return output.MarkRaw(output.ErrNetwork("API call failed: %v", err)) + } + if apiErr := client.CheckLarkResponse(result); apiErr != nil { + output.FormatValue(out, result, output.FormatJSON) + return output.MarkRaw(apiErr) + } + output.FormatValue(out, result, format) + return nil + } +} diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go new file mode 100644 index 00000000..362a6c02 --- /dev/null +++ b/cmd/api/api_test.go @@ -0,0 +1,558 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package api + +import ( + "errors" + "sort" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +func TestApiCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *APIOptions + cmd := NewCmdApi(f, func(opts *APIOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Method != "GET" { + t.Errorf("expected method GET, got %s", gotOpts.Method) + } + if gotOpts.Path != "/open-apis/test" { + t.Errorf("expected path /open-apis/test, got %s", gotOpts.Path) + } + if gotOpts.As != core.AsBot { + t.Errorf("expected as=bot, got %s", gotOpts.As) + } + if !gotOpts.DryRun { + t.Error("expected DryRun=true") + } +} + +func TestApiCmd_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "Dry Run") { + t.Error("expected dry run output") + } + if !strings.Contains(output, "/open-apis/test") { + t.Error("expected path in dry run output") + } +} + +func TestApiCmd_BotMode(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + // Register tenant_access_token stub + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "tenant_access_token": "t-test-token", + "expire": 7200, + }, + }) + // Register API endpoint stub + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}}, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "success") { + t.Error("expected 'success' in output") + } +} + +func TestApiCmd_MissingArgs(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET"}) // missing path + err := cmd.Execute() + if err == nil { + t.Error("expected error for missing args") + } +} + +func TestApiCmd_InvalidParamsJSON(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"}) + err := cmd.Execute() + if err == nil { + t.Error("expected validation error for invalid JSON") + } +} + +func TestApiValidArgsFunction(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, nil) + fn := cmd.ValidArgsFunction + + tests := []struct { + name string + args []string + toComplete string + wantComps []string + wantDir cobra.ShellCompDirective + }{ + { + name: "no args returns HTTP methods", + args: []string{}, + toComplete: "", + wantComps: []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, + wantDir: cobra.ShellCompDirectiveNoFileComp, + }, + { + name: "one arg returns nil with NoFileComp", + args: []string{"GET"}, + toComplete: "", + wantComps: nil, + wantDir: cobra.ShellCompDirectiveNoFileComp, + }, + { + name: "two args returns nil with NoFileComp", + args: []string{"GET", "/path"}, + toComplete: "", + wantComps: nil, + wantDir: cobra.ShellCompDirectiveNoFileComp, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + comps, dir := fn(cmd, tt.args, tt.toComplete) + if dir != tt.wantDir { + t.Errorf("directive = %d, want %d", dir, tt.wantDir) + } + if tt.wantComps == nil { + if comps != nil { + t.Errorf("completions = %v, want nil", comps) + } + return + } + sort.Strings(comps) + sort.Strings(tt.wantComps) + if len(comps) != len(tt.wantComps) { + t.Errorf("completions = %v, want %v", comps, tt.wantComps) + return + } + for i := range comps { + if comps[i] != tt.wantComps[i] { + t.Errorf("completions = %v, want %v", comps, tt.wantComps) + break + } + } + }) + } +} + +func TestApiCmd_PageLimitDefault(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *APIOptions + cmd := NewCmdApi(f, func(opts *APIOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"GET", "/open-apis/test"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.PageLimit != 10 { + t.Errorf("expected default PageLimit=10, got %d", gotOpts.PageLimit) + } +} + +func TestApiCmd_OutputAndPageAllConflict(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *APIOptions + cmd := NewCmdApi(f, func(opts *APIOptions) error { + gotOpts = opts + return apiRun(opts) + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--page-all", "--output", "file.bin"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --output + --page-all conflict") + } + if gotOpts != nil && !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) { + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-bin", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/drive/v1/files/xxx/download", + RawBody: []byte("fake-binary-content"), + ContentType: "application/octet-stream", + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stderr.String(), "binary response detected") { + t.Error("expected binary response hint in stderr") + } + if !strings.Contains(stdout.String(), "saved_path") { + t.Error("expected saved_path in output") + } +} + +func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) { + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu, + }) + + // Register tenant_access_token stub + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-pa1", "expire": 7200, + }, + }) + // Register a non-batch API that returns scalar data (no array field) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/contact/v3/users/u123", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "user_id": "u123", + "name": "Test User", + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should print fallback warning to stderr + if !strings.Contains(stderr.String(), "warning: this API does not return a list") { + t.Error("expected fallback warning in stderr") + } + if !strings.Contains(stderr.String(), "falling back to json") { + t.Error("expected 'falling back to json' in stderr") + } + // Should output JSON result to stdout + if !strings.Contains(stdout.String(), "u123") { + t.Error("expected user_id in JSON output") + } +} + +func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-err", "expire": 7200, + }, + }) + // Non-batch API that returns a business error (code != 0) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/im/v1/chats/oc_xxx/announcement", + Body: map[string]interface{}{ + "code": 230001, "msg": "no permission", + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"}) + err := cmd.Execute() + // Should return an error + if err == nil { + t.Fatal("expected an error for non-zero code") + } + // Should still output the response body so user can see the error details + if !strings.Contains(stdout.String(), "230001") { + t.Errorf("expected error response in stdout, got: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), "no permission") { + t.Errorf("expected error message in stdout, got: %s", stdout.String()) + } +} + +func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) { + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu, + }) + + // Register tenant_access_token stub (unique app credentials => new token request) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-pa2", "expire": 7200, + }, + }) + // Register a batch API that returns an array field + reg.Register(&httpmock.Stub{ + URL: "/open-apis/contact/v3/users", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}, + "has_more": false, + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should NOT print fallback warning + if strings.Contains(stderr.String(), "warning: this API does not return a list") { + t.Error("expected no fallback warning for batch API") + } + // Should stream ndjson items + if !strings.Contains(stdout.String(), `"id"`) { + t.Error("expected streamed items in output") + } +} + +func TestNormalisePath_StripsQueryAndFragment(t *testing.T) { + for _, tt := range []struct { + name string + raw string + want string + }{ + {"plain path", "/open-apis/test", "/open-apis/test"}, + {"with query", "/open-apis/test?admin=true", "/open-apis/test"}, + {"with fragment", "/open-apis/test#section", "/open-apis/test"}, + {"with both", "/open-apis/test?a=1#frag", "/open-apis/test"}, + {"full URL with query", "https://open.feishu.cn/open-apis/foo?bar=1", "/open-apis/foo"}, + {"short path with query", "contact/v3/users?page_size=50", "/open-apis/contact/v3/users"}, + } { + t.Run(tt.name, func(t *testing.T) { + got := normalisePath(tt.raw) + if got != tt.want { + t.Errorf("normalisePath(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +func TestApiCmd_APIError_IsRaw(t *testing.T) { + f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-raw", "expire": 7200, + }, + }) + // Return a permission error from the API + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/perm", + Body: map[string]interface{}{ + "code": 99991672, + "msg": "scope not enabled for this app", + "error": map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "calendar:calendar:readonly"}, + }, + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test/perm", "--as", "bot"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for permission denied API response") + } + + // Error should be marked Raw + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if !exitErr.Raw { + t.Error("expected API error from api command to be marked Raw") + } + + // stderr should NOT contain an error envelope (identity line is OK) + if strings.Contains(stderr.String(), `"ok"`) { + t.Error("expected no JSON error envelope on stderr for Raw API error") + } +} + +func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-origmsg", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/origmsg", + Body: map[string]interface{}{ + "code": 99991672, + "msg": "scope not enabled for this app", + "error": map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "im:message:readonly"}, + }, + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test/origmsg", "--as", "bot"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + // The message should NOT have been enriched (no "App scope not enabled" replacement) + if strings.Contains(exitErr.Error(), "App scope not enabled") { + t.Error("expected original message, not enriched message") + } + // Detail should still contain the raw API error detail + if exitErr.Detail == nil { + t.Fatal("expected non-nil Detail") + } + if exitErr.Detail.Detail == nil { + t.Error("expected raw Detail.Detail to be preserved (not cleared by enrichment)") + } +} + +func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-rawpage", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/rawpage", + Body: map[string]interface{}{ + "code": 99991672, + "msg": "scope not enabled", + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test/rawpage", "--as", "bot", "--page-all"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error") + } + + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected *output.ExitError, got %T", err) + } + if !exitErr.Raw { + t.Error("expected paginated API error to be marked Raw") + } +} + +func TestApiCmd_MethodUppercase(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *APIOptions + cmd := NewCmdApi(f, func(opts *APIOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"post", "/test"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Method != "POST" { + t.Errorf("expected method POST (uppercased), got %s", gotOpts.Method) + } +} diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go new file mode 100644 index 00000000..f8a4f1c8 --- /dev/null +++ b/cmd/auth/auth.go @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" +) + +// NewCmdAuth creates the auth command with subcommands. +func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "auth", + Short: "OAuth credentials and authorization management", + } + cmdutil.DisableAuthCheck(cmd) + + cmd.AddCommand(NewCmdAuthLogin(f, nil)) + cmd.AddCommand(NewCmdAuthLogout(f, nil)) + cmd.AddCommand(NewCmdAuthStatus(f, nil)) + cmd.AddCommand(NewCmdAuthScopes(f, nil)) + cmd.AddCommand(NewCmdAuthList(f, nil)) + cmd.AddCommand(NewCmdAuthCheck(f, nil)) + return cmd +} + +// userInfoResponse is the API response for /open-apis/authen/v1/user_info. +type userInfoResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + OpenID string `json:"open_id"` + Name string `json:"name"` + } `json:"data"` +} + +// getUserInfo fetches the current user's OpenID and name using the given access token. +func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (openId, name string, err error) { + apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/authen/v1/user_info", + SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}, + }, larkcore.WithUserAccessToken(accessToken)) + if err != nil { + return "", "", err + } + + var resp userInfoResponse + if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { + return "", "", fmt.Errorf("failed to parse user info: %v", err) + } + if resp.Code != 0 { + return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg) + } + if resp.Data.OpenID == "" { + return "", "", fmt.Errorf("failed to get user info: missing open_id in response") + } + + name = resp.Data.Name + if name == "" { + name = "(unknown)" + } + return resp.Data.OpenID, name, nil +} + +// appInfo contains application information (owner, scopes). +type appInfo struct { + OwnerOpenId string + UserScopes []string +} + +// appInfoResponse is the API response for /open-apis/application/v6/applications/:app_id. +type appInfoResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + App struct { + Owner struct { + OwnerID string `json:"owner_id"` + } `json:"owner"` + CreatorID string `json:"creator_id"` + Scopes []struct { + Scope string `json:"scope"` + TokenTypes []string `json:"token_types"` + } `json:"scopes"` + } `json:"app"` + } `json:"data"` +} + +// getAppInfo queries app info from the Lark API. +func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) { + sdk, err := f.LarkClient() + if err != nil { + return nil, err + } + + queryParams := make(larkcore.QueryParams) + queryParams.Set("lang", "zh_cn") + + apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/application/v6/applications/" + appId, + QueryParams: queryParams, + SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}, + }) + if err != nil { + return nil, err + } + + var resp appInfoResponse + if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { + return nil, fmt.Errorf("failed to parse response: %v", err) + } + if resp.Code != 0 { + return nil, fmt.Errorf("API error [%d]: %s", resp.Code, resp.Msg) + } + + app := resp.Data.App + ownerOpenId := app.Owner.OwnerID + if ownerOpenId == "" { + ownerOpenId = app.CreatorID + } + + var userScopes []string + for _, s := range app.Scopes { + if s.Scope == "" || !slices.Contains(s.TokenTypes, "user") { + continue + } + userScopes = append(userScopes, s.Scope) + } + + return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil +} diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go new file mode 100644 index 00000000..15bd828f --- /dev/null +++ b/cmd/auth/auth_test.go @@ -0,0 +1,233 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "sort" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/registry" +) + +func TestAuthLoginCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *LoginOptions + cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Scope != "calendar:calendar:read" { + t.Errorf("expected scope calendar:calendar:read, got %s", gotOpts.Scope) + } + if !gotOpts.JSON { + t.Error("expected JSON=true") + } +} + +func TestAuthCheckCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *CheckOptions + cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--scope", "calendar:calendar:read drive:drive:read"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Scope != "calendar:calendar:read drive:drive:read" { + t.Errorf("expected scope string, got %s", gotOpts.Scope) + } +} + +func TestAuthLogoutCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *LogoutOptions + cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Error("expected opts to be set") + } +} + +func TestAuthListCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *ListOptions + cmd := NewCmdAuthList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Error("expected opts to be set") + } +} + +func TestAuthStatusCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *StatusOptions + cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error { + gotOpts = opts + return nil + }) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Error("expected opts to be set") + } +} + +func TestAuthStatusCmd_VerifyFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *StatusOptions + cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--verify"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Fatal("expected opts to be set") + } + if !gotOpts.Verify { + t.Error("expected Verify=true when --verify flag is passed") + } +} + +func TestDomainFlagCompletion(t *testing.T) { + allDomains := registry.ListFromMetaProjects() + + tests := []struct { + name string + toComplete string + wantContains []string + wantExclude []string + }{ + { + name: "empty returns all domains", + toComplete: "", + wantContains: allDomains, + }, + { + name: "partial match", + toComplete: "cal", + wantContains: []string{"calendar"}, + wantExclude: []string{"bitable", "drive", "task"}, + }, + { + name: "comma prefix completes second value", + toComplete: "calendar,", + wantContains: func() []string { + var out []string + for _, d := range allDomains { + out = append(out, "calendar,"+d) + } + return out + }(), + }, + { + name: "comma with partial second value", + toComplete: "calendar,ta", + wantContains: []string{"calendar,task"}, + wantExclude: []string{"calendar,bitable", "calendar,drive"}, + }, + { + name: "no match returns empty", + toComplete: "xxx", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + comps := completeDomain(tt.toComplete) + sort.Strings(comps) + + for _, want := range tt.wantContains { + found := false + for _, c := range comps { + if c == want { + found = true + break + } + } + if !found { + t.Errorf("completions %v missing expected %q", comps, want) + } + } + + for _, exclude := range tt.wantExclude { + for _, c := range comps { + if c == exclude { + t.Errorf("completions %v should not contain %q", comps, exclude) + } + } + } + + // Verify no completion contains trailing comma artifacts + for _, c := range comps { + if strings.HasSuffix(c, ",") { + t.Errorf("completion %q should not end with comma", c) + } + } + }) + } +} + +func TestAuthScopesCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *ScopesOptions + cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--format", "json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Format != "json" { + t.Errorf("expected format json, got %s", gotOpts.Format) + } +} diff --git a/cmd/auth/check.go b/cmd/auth/check.go new file mode 100644 index 00000000..5f0bd0f4 --- /dev/null +++ b/cmd/auth/check.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" +) + +// CheckOptions holds all inputs for auth check. +type CheckOptions struct { + Factory *cmdutil.Factory + Scope string +} + +// NewCmdAuthCheck creates the auth check subcommand. +func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Command { + opts := &CheckOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "check", + Short: "Check if current token has specified scopes", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return authCheckRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)") + cmd.MarkFlagRequired("scope") + + return cmd +} + +func authCheckRun(opts *CheckOptions) error { + f := opts.Factory + + required := strings.Fields(opts.Scope) + if len(required) == 0 { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}}) + return nil + } + + config, err := f.Config() + if err != nil { + return err + } + if config.UserOpenId == "" { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "not_logged_in", "missing": required}) + return output.ErrBare(1) + } + + stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId) + if stored == nil { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "no_token", "missing": required}) + return output.ErrBare(1) + } + + missing := larkauth.MissingScopes(stored.Scope, required) + missingSet := make(map[string]bool, len(missing)) + for _, s := range missing { + missingSet[s] = true + } + var granted []string + for _, s := range required { + if !missingSet[s] { + granted = append(granted, s) + } + } + + ok := len(missing) == 0 + result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing} + if len(missing) > 0 { + result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " ")) + } + output.PrintJson(f.IOStreams.Out, result) + if !ok { + return output.ErrBare(1) + } + return nil +} diff --git a/cmd/auth/list.go b/cmd/auth/list.go new file mode 100644 index 00000000..24086959 --- /dev/null +++ b/cmd/auth/list.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// ListOptions holds all inputs for auth list. +type ListOptions struct { + Factory *cmdutil.Factory +} + +// NewCmdAuthList creates the auth list subcommand. +func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "list", + Short: "List all logged-in users", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return authListRun(opts) + }, + } + + return cmd +} + +func authListRun(opts *ListOptions) error { + f := opts.Factory + + multi, _ := core.LoadMultiAppConfig() + if multi == nil || len(multi.Apps) == 0 { + fmt.Fprintln(f.IOStreams.ErrOut, "Not configured yet. Run `lark-cli config init` to initialize.") + return nil + } + + app := multi.Apps[0] + if len(app.Users) == 0 { + fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.") + return nil + } + + var items []map[string]interface{} + for _, u := range app.Users { + stored := larkauth.GetStoredToken(app.AppId, u.UserOpenId) + status := "no_token" + if stored != nil { + status = larkauth.TokenStatus(stored) + } + items = append(items, map[string]interface{}{ + "userName": u.UserName, + "userOpenId": u.UserOpenId, + "appId": app.AppId, + "tokenStatus": status, + }) + } + output.PrintJson(f.IOStreams.Out, items) + return nil +} diff --git a/cmd/auth/login.go b/cmd/auth/login.go new file mode 100644 index 00000000..572965d3 --- /dev/null +++ b/cmd/auth/login.go @@ -0,0 +1,475 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts" + "github.com/larksuite/cli/shortcuts/common" +) + +// LoginOptions holds all inputs for auth login. +type LoginOptions struct { + Factory *cmdutil.Factory + Ctx context.Context + JSON bool + Scope string + Recommend bool + Domains []string + NoWait bool + DeviceCode string +} + +// NewCmdAuthLogin creates the auth login subcommand. +func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { + opts := &LoginOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "login", + Short: "Device Flow authorization login", + Long: `Device Flow authorization login. + +For AI agents: this command blocks until the user completes authorization in the +browser. Run it in the background and retrieve the verification URL from its output.`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Ctx = cmd.Context() + if runF != nil { + return runF(opts) + } + return authLoginRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)") + cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes") + available := sortedKnownDomains() + cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil, + fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", "))) + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") + cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete") + cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call") + + _ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp + }) + + return cmd +} + +// completeDomain returns completions for comma-separated domain values. +func completeDomain(toComplete string) []string { + allDomains := registry.ListFromMetaProjects() + parts := strings.Split(toComplete, ",") + prefix := parts[len(parts)-1] + base := strings.Join(parts[:len(parts)-1], ",") + + var completions []string + for _, d := range allDomains { + if strings.HasPrefix(d, prefix) { + if base == "" { + completions = append(completions, d) + } else { + completions = append(completions, base+","+d) + } + } + } + return completions +} + +func authLoginRun(opts *LoginOptions) error { + f := opts.Factory + + config, err := f.Config() + if err != nil { + return err + } + + // Determine UI language from saved config + lang := "zh" + if multi, _ := core.LoadMultiAppConfig(); multi != nil && len(multi.Apps) > 0 { + lang = multi.Apps[0].Lang + } + msg := getLoginMsg(lang) + + log := func(format string, a ...interface{}) { + if !opts.JSON { + fmt.Fprintf(f.IOStreams.ErrOut, format+"\n", a...) + } + } + + // --device-code: resume polling from a previous --no-wait call + if opts.DeviceCode != "" { + return authLoginPollDeviceCode(opts, config, msg, log) + } + + selectedDomains := opts.Domains + scopeLevel := "" // "common" or "all" (from interactive mode) + + // Expand --domain all to all available domains (from_meta projects + shortcut services) + for _, d := range selectedDomains { + if strings.EqualFold(d, "all") { + domainSet := make(map[string]bool) + for _, p := range registry.ListFromMetaProjects() { + domainSet[p] = true + } + for _, sc := range shortcuts.AllShortcuts() { + domainSet[sc.Service] = true + } + selectedDomains = make([]string, 0, len(domainSet)) + for d := range domainSet { + selectedDomains = append(selectedDomains, d) + } + sort.Strings(selectedDomains) + break + } + } + + // Validate domain names and suggest corrections for unknown ones + if len(selectedDomains) > 0 { + knownDomains := allKnownDomains() + for _, d := range selectedDomains { + if !knownDomains[d] { + if suggestion := suggestDomain(d, knownDomains); suggestion != "" { + return output.ErrValidation("unknown domain %q, did you mean %q?", d, suggestion) + } + available := make([]string, 0, len(knownDomains)) + for k := range knownDomains { + available = append(available, k) + } + sort.Strings(available) + return output.ErrValidation("unknown domain %q, available domains: %s", d, strings.Join(available, ", ")) + } + } + } + + hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0 + + if !hasAnyOption { + if !opts.JSON && f.IOStreams.IsTerminal { + result, err := runInteractiveLogin(f.IOStreams, lang, msg) + if err != nil { + return err + } + if result == nil { + return output.ErrValidation("no login options selected") + } + selectedDomains = result.Domains + scopeLevel = result.ScopeLevel + } else { + log(msg.HintHeader) + log("Common options:") + log(msg.HintCommon1) + log(msg.HintCommon2) + log(msg.HintCommon3) + log(msg.HintCommon4) + log("") + log("View all options:") + log(msg.HintFooter) + log("") + log("Note: this command blocks until authorization is complete. Run it in the background and retrieve the verification URL from its output.") + return output.ErrValidation("please specify the scopes to authorize") + } + } + + finalScope := opts.Scope + + // Resolve scopes from domain/permission filters + if len(selectedDomains) > 0 || opts.Recommend { + if opts.Scope != "" { + return output.ErrValidation("cannot use --scope together with --domain/--recommend") + } + + var candidateScopes []string + if len(selectedDomains) > 0 { + candidateScopes = collectScopesForDomains(selectedDomains, "user") + } else { + // --recommend without --domain: all domains + candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user") + } + + // Filter to auto-approve scopes if --recommend or interactive "common" + if opts.Recommend || scopeLevel == "common" { + candidateScopes = registry.FilterAutoApproveScopes(candidateScopes) + } + + if len(candidateScopes) == 0 { + return output.ErrValidation("no matching scopes found, check domain/scope options") + } + + finalScope = strings.Join(candidateScopes, " ") + } + + // Step 1: Request device authorization + httpClient, err := f.HttpClient() + if err != nil { + return err + } + authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut) + if err != nil { + return output.ErrAuth("device authorization failed: %v", err) + } + + // --no-wait: return immediately with device code and URL + if opts.NoWait { + b, _ := json.Marshal(map[string]interface{}{ + "verification_url": authResp.VerificationUriComplete, + "device_code": authResp.DeviceCode, + "expires_in": authResp.ExpiresIn, + "hint": fmt.Sprintf("Show verification_url to user, then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode), + }) + fmt.Fprintln(f.IOStreams.Out, string(b)) + return nil + } + + // Step 2: Show user code and verification URL + if opts.JSON { + b, _ := json.Marshal(map[string]interface{}{ + "event": "device_authorization", + "verification_uri": authResp.VerificationUri, + "verification_uri_complete": authResp.VerificationUriComplete, + "user_code": authResp.UserCode, + "expires_in": authResp.ExpiresIn, + }) + fmt.Fprintln(f.IOStreams.Out, string(b)) + } else { + fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL) + fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete) + } + + // Step 3: Poll for token + log(msg.WaitingAuth) + result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand, + authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) + + if !result.OK { + if opts.JSON { + b, _ := json.Marshal(map[string]interface{}{ + "event": "authorization_failed", + "error": result.Message, + }) + fmt.Fprintln(f.IOStreams.Out, string(b)) + return output.ErrBare(output.ExitAuth) + } + return output.ErrAuth("authorization failed: %s", result.Message) + } + + // Step 6: Get user info + log(msg.AuthSuccess) + sdk, err := f.LarkClient() + if err != nil { + return output.ErrAuth("failed to get SDK: %v", err) + } + openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken) + if err != nil { + return output.ErrAuth("failed to get user info: %v", err) + } + + // Step 7: Store token + now := time.Now().UnixMilli() + storedToken := &larkauth.StoredUAToken{ + UserOpenId: openId, + AppId: config.AppID, + AccessToken: result.Token.AccessToken, + RefreshToken: result.Token.RefreshToken, + ExpiresAt: now + int64(result.Token.ExpiresIn)*1000, + RefreshExpiresAt: now + int64(result.Token.RefreshExpiresIn)*1000, + Scope: result.Token.Scope, + GrantedAt: now, + } + if err := larkauth.SetStoredToken(storedToken); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err) + } + + // Step 8: Update config — overwrite Users to single user, clean old tokens + multi, _ := core.LoadMultiAppConfig() + if multi != nil && len(multi.Apps) > 0 { + app := &multi.Apps[0] + for _, oldUser := range app.Users { + if oldUser.UserOpenId != openId { + larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId) + } + } + app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}} + if err := core.SaveMultiAppConfig(multi); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + } + + if opts.JSON { + b, _ := json.Marshal(map[string]interface{}{ + "event": "authorization_complete", + "user_open_id": openId, + "user_name": userName, + "scope": result.Token.Scope, + }) + fmt.Fprintln(f.IOStreams.Out, string(b)) + } else { + fmt.Fprintln(f.IOStreams.ErrOut) + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId)) + if result.Token.Scope != "" { + fmt.Fprintf(f.IOStreams.ErrOut, msg.GrantedScopes, result.Token.Scope) + } + } + return nil +} + +// authLoginPollDeviceCode resumes the device flow by polling with a device code +// obtained from a previous --no-wait call. +func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *loginMsg, log func(string, ...interface{})) error { + f := opts.Factory + + httpClient, err := f.HttpClient() + if err != nil { + return err + } + log(msg.WaitingAuth) + result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand, + opts.DeviceCode, 5, 180, f.IOStreams.ErrOut) + + if !result.OK { + return output.ErrAuth("authorization failed: %s", result.Message) + } + if result.Token == nil { + return output.ErrAuth("authorization succeeded but no token returned") + } + + // Get user info + log(msg.AuthSuccess) + sdk, err := f.LarkClient() + if err != nil { + return output.ErrAuth("failed to get SDK: %v", err) + } + openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken) + if err != nil { + return output.ErrAuth("failed to get user info: %v", err) + } + + // Store token + now := time.Now().UnixMilli() + storedToken := &larkauth.StoredUAToken{ + UserOpenId: openId, + AppId: config.AppID, + AccessToken: result.Token.AccessToken, + RefreshToken: result.Token.RefreshToken, + ExpiresAt: now + int64(result.Token.ExpiresIn)*1000, + RefreshExpiresAt: now + int64(result.Token.RefreshExpiresIn)*1000, + Scope: result.Token.Scope, + GrantedAt: now, + } + if err := larkauth.SetStoredToken(storedToken); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err) + } + + // Update config — overwrite Users to single user, clean old tokens + multi, _ := core.LoadMultiAppConfig() + if multi != nil && len(multi.Apps) > 0 { + app := &multi.Apps[0] + for _, oldUser := range app.Users { + if oldUser.UserOpenId != openId { + larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId) + } + } + app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}} + if err := core.SaveMultiAppConfig(multi); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + } + + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId)) + return nil +} + +// collectScopesForDomains collects API scopes (from from_meta projects) and +// shortcut scopes for the given domain names. +func collectScopesForDomains(domains []string, identity string) []string { + scopeSet := make(map[string]bool) + + // 1. API scopes from from_meta projects + for _, s := range registry.CollectScopesForProjects(domains, identity) { + scopeSet[s] = true + } + + // 2. Shortcut scopes matching by Service (only include shortcuts supporting the identity) + domainSet := make(map[string]bool, len(domains)) + for _, d := range domains { + domainSet[d] = true + } + for _, sc := range shortcuts.AllShortcuts() { + if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) { + for _, s := range sc.ScopesForIdentity(identity) { + scopeSet[s] = true + } + } + } + + // 3. Deduplicate and sort + result := make([]string, 0, len(scopeSet)) + for s := range scopeSet { + result = append(result, s) + } + sort.Strings(result) + return result +} + +// allKnownDomains returns all valid domain names (from_meta projects + shortcut services). +func allKnownDomains() map[string]bool { + domains := make(map[string]bool) + for _, p := range registry.ListFromMetaProjects() { + domains[p] = true + } + for _, sc := range shortcuts.AllShortcuts() { + domains[sc.Service] = true + } + return domains +} + +// sortedKnownDomains returns all valid domain names sorted alphabetically. +func sortedKnownDomains() []string { + m := allKnownDomains() + domains := make([]string, 0, len(m)) + for d := range m { + domains = append(domains, d) + } + sort.Strings(domains) + return domains +} + +// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot"). +// Empty AuthTypes defaults to ["user"]. +func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool { + authTypes := sc.AuthTypes + if len(authTypes) == 0 { + authTypes = []string{"user"} + } + for _, t := range authTypes { + if t == identity { + return true + } + } + return false +} + +// suggestDomain finds the best "did you mean" match for an unknown domain. +func suggestDomain(input string, known map[string]bool) string { + // Check common cases: prefix match or input is a substring + for k := range known { + if strings.HasPrefix(k, input) || strings.HasPrefix(input, k) { + return k + } + } + return "" +} diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go new file mode 100644 index 00000000..486d3d50 --- /dev/null +++ b/cmd/auth/login_interactive.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/huh" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts" +) + +// domainMeta describes a domain for the interactive selector. +type domainMeta struct { + Name string + Title string + Description string +} + +// interactiveResult holds the user's selections from the interactive form. +type interactiveResult struct { + Domains []string + ScopeLevel string // "common" or "all" +} + +// getDomainMetadata returns metadata for all known domains, sorted by name. +func getDomainMetadata(lang string) []domainMeta { + seen := make(map[string]bool) + var domains []domainMeta + + // 1. Domains from from_meta projects + for _, project := range registry.ListFromMetaProjects() { + dm := buildDomainMeta(project, lang) + domains = append(domains, dm) + seen[project] = true + } + + // 2. Shortcut-only domains + shortcutOnlyNames := getShortcutOnlyDomainNames() + for _, name := range shortcutOnlyNames { + if !seen[name] { + dm := buildDomainMeta(name, lang) + domains = append(domains, dm) + seen[name] = true + } + } + + // 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains + shortcutOnlySet := make(map[string]bool) + for _, n := range shortcutOnlyNames { + shortcutOnlySet[n] = true + } + for _, sc := range shortcuts.AllShortcuts() { + if !seen[sc.Service] { + if shortcutOnlySet[sc.Service] { + dm := buildDomainMeta(sc.Service, lang) + domains = append(domains, dm) + } + seen[sc.Service] = true + } + } + + sort.Slice(domains, func(i, j int) bool { + return domains[i].Name < domains[j].Name + }) + return domains +} + +// buildDomainMeta constructs a domainMeta for a given service name and language. +// It reads from the service_descriptions.json config first, falling back to +// from_meta spec fields if not found. +func buildDomainMeta(name, lang string) domainMeta { + title := registry.GetServiceTitle(name, lang) + desc := registry.GetServiceDetailDescription(name, lang) + if title != "" || desc != "" { + return domainMeta{ + Name: name, + Title: title, + Description: desc, + } + } + // Fallback: read from from_meta spec (legacy) + meta := registry.LoadFromMeta(name) + dm := domainMeta{Name: name} + if meta != nil { + if t, ok := meta["title"].(string); ok { + dm.Title = t + } + if d, ok := meta["description"].(string); ok { + dm.Description = d + } + } + return dm +} + +// runInteractiveLogin shows an interactive TUI form for domain and permission selection. +func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) { + allDomains := getDomainMetadata(lang) + + // Build multi-select options + options := make([]huh.Option[string], len(allDomains)) + for i, dm := range allDomains { + var label string + switch { + case dm.Title != "" && dm.Description != "": + label = fmt.Sprintf("%-12s %s - %s", dm.Name, dm.Title, dm.Description) + case dm.Title != "": + label = fmt.Sprintf("%-12s %s", dm.Name, dm.Title) + default: + label = fmt.Sprintf("%-12s %s", dm.Name, dm.Description) + } + options[i] = huh.NewOption(label, dm.Name) + } + + var selectedDomains []string + var permLevel string + + // Phase 1a: domain selection + // Phase 1b: permission level (shown after domain selection completes) + form1 := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title(msg.SelectDomains). + Description(msg.DomainHint). + Options(options...). + Value(&selectedDomains). + Validate(func(s []string) error { + if len(s) == 0 { + return fmt.Errorf(msg.ErrNoDomain) + } + return nil + }), + ), + huh.NewGroup( + huh.NewSelect[string](). + Title(msg.PermLevel). + Options( + huh.NewOption(msg.PermCommon, "common"), + huh.NewOption(msg.PermAll, "all"), + ). + Value(&permLevel), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form1.Run(); err != nil { + if err == huh.ErrUserAborted { + return nil, output.ErrBare(1) + } + return nil, err + } + + if len(selectedDomains) == 0 { + return nil, output.ErrValidation("no domains selected") + } + + // Compute scope summary + scopes := collectScopesForDomains(selectedDomains, "user") + if permLevel == "common" { + scopes = registry.FilterAutoApproveScopes(scopes) + } + + // Print summary + permLabel := msg.PermAllLabel + if permLevel == "common" { + permLabel = msg.PermCommonLabel + } + fmt.Fprintf(ios.ErrOut, msg.Summary) + fmt.Fprintf(ios.ErrOut, msg.SummaryDomains, strings.Join(selectedDomains, ", ")) + fmt.Fprintf(ios.ErrOut, msg.SummaryPerm, permLabel) + scopePreview := strings.Join(scopes, ", ") + if len(scopePreview) > 80 { + scopePreview = strings.Join(scopes[:3], ", ") + ", ..." + } + fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview) + + // Phase 2: confirmation + var confirmed bool + form2 := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title(msg.ConfirmAuth). + Value(&confirmed), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form2.Run(); err != nil { + if err == huh.ErrUserAborted { + return nil, output.ErrBare(1) + } + return nil, err + } + + if !confirmed { + return nil, output.ErrBare(1) + } + + return &interactiveResult{ + Domains: selectedDomains, + ScopeLevel: permLevel, + }, nil +} diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go new file mode 100644 index 00000000..f1634967 --- /dev/null +++ b/cmd/auth/login_messages.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +type loginMsg struct { + // Interactive UI (login_interactive.go) + SelectDomains string + DomainHint string + PermLevel string + PermCommon string + PermAll string + Summary string + SummaryDomains string + SummaryPerm string + SummaryScopes string + PermAllLabel string + PermCommonLabel string + ErrNoDomain string + ConfirmAuth string + + // Non-interactive prompts (login.go) + OpenURL string + WaitingAuth string + AuthSuccess string + LoginSuccess string + GrantedScopes string + + // Non-interactive hint (no flags) + HintHeader string + HintCommon1 string + HintCommon2 string + HintCommon3 string + HintCommon4 string + HintFooter string +} + +var loginMsgZh = &loginMsg{ + SelectDomains: "选择要授权的业务域", + DomainHint: "空格=选择, 回车=确认", + PermLevel: "权限类型", + PermCommon: "常用权限", + PermAll: "全部权限", + Summary: "\n摘要:\n", + SummaryDomains: " 域: %s\n", + SummaryPerm: " 权限: %s\n", + SummaryScopes: " Scopes (%d): %s\n\n", + PermAllLabel: "全部权限", + PermCommonLabel: "常用权限", + ErrNoDomain: "请至少选择一个业务域", + ConfirmAuth: "确认授权?", + + OpenURL: "在浏览器中打开以下链接进行认证:\n\n", + WaitingAuth: "等待用户授权...", + AuthSuccess: "授权成功,正在获取用户信息...", + LoginSuccess: "登录成功! 用户: %s (%s)", + GrantedScopes: " 已授权 scopes: %s\n", + + HintHeader: "请指定要授权的权限:\n", + HintCommon1: " --recommend 授权推荐权限", + HintCommon2: " --domain all 授权所有已知域的权限", + HintCommon3: " --domain calendar,task 授权日历和任务域的权限", + HintCommon4: " --domain calendar --recommend 授权日历域的推荐权限", + HintFooter: " lark-cli auth login --help", +} + +var loginMsgEn = &loginMsg{ + SelectDomains: "Select domains to authorize", + DomainHint: "Space=toggle, Enter=confirm", + PermLevel: "Permission level", + PermCommon: "Common scopes", + PermAll: "All scopes", + Summary: "\nSummary:\n", + SummaryDomains: " Domains: %s\n", + SummaryPerm: " Level: %s\n", + SummaryScopes: " Scopes (%d): %s\n\n", + PermAllLabel: "All scopes", + PermCommonLabel: "Common scopes", + ErrNoDomain: "please select at least one domain", + ConfirmAuth: "Confirm authorization?", + + OpenURL: "Open this URL in your browser to authenticate:\n\n", + WaitingAuth: "Waiting for user authorization...", + AuthSuccess: "Authorization successful, fetching user info...", + LoginSuccess: "Login successful! User: %s (%s)", + GrantedScopes: " Granted scopes: %s\n", + + HintHeader: "Please specify the scopes to authorize:\n", + HintCommon1: " --recommend authorize recommended scopes", + HintCommon2: " --domain all authorize all known domain scopes", + HintCommon3: " --domain calendar,task authorize calendar and task scopes", + HintCommon4: " --domain calendar --recommend authorize calendar recommended scopes", + HintFooter: " lark-cli auth login --help", +} + +func getLoginMsg(lang string) *loginMsg { + if lang == "en" { + return loginMsgEn + } + return loginMsgZh +} + +// getShortcutOnlyDomainNames returns domain names that exist only as shortcuts +// (not backed by from_meta service specs). Descriptions are now centralized in +// service_descriptions.json. +func getShortcutOnlyDomainNames() []string { + return []string{"base", "contact", "docs"} +} diff --git a/cmd/auth/login_messages_test.go b/cmd/auth/login_messages_test.go new file mode 100644 index 00000000..500866de --- /dev/null +++ b/cmd/auth/login_messages_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + "reflect" + "testing" +) + +func TestGetLoginMsg_Zh(t *testing.T) { + msg := getLoginMsg("zh") + if msg != loginMsgZh { + t.Error("expected zh message set") + } + if msg.SelectDomains != "选择要授权的业务域" { + t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains) + } +} + +func TestGetLoginMsg_En(t *testing.T) { + msg := getLoginMsg("en") + if msg != loginMsgEn { + t.Error("expected en message set") + } + if msg.SelectDomains != "Select domains to authorize" { + t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains) + } +} + +func TestGetLoginMsg_DefaultsToZh(t *testing.T) { + for _, lang := range []string{"", "fr", "ja", "unknown"} { + msg := getLoginMsg(lang) + if msg != loginMsgZh { + t.Errorf("getLoginMsg(%q) should default to zh", lang) + } + } +} + +func TestLoginMsgZh_AllFieldsNonEmpty(t *testing.T) { + assertLoginMsgAllFieldsNonEmpty(t, loginMsgZh, "zh") +} + +func TestLoginMsgEn_AllFieldsNonEmpty(t *testing.T) { + assertLoginMsgAllFieldsNonEmpty(t, loginMsgEn, "en") +} + +func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string) { + t.Helper() + v := reflect.ValueOf(*msg) + typ := v.Type() + for i := 0; i < v.NumField(); i++ { + field := typ.Field(i) + val := v.Field(i).String() + if val == "" { + t.Errorf("%s.%s is empty", label, field.Name) + } + } +} + +func TestLoginMsg_FormatStrings(t *testing.T) { + for _, lang := range []string{"zh", "en"} { + msg := getLoginMsg(lang) + + // LoginSuccess should contain two %s placeholders (userName, openId) + got := fmt.Sprintf(msg.LoginSuccess, "testuser", "ou_123") + if got == msg.LoginSuccess { + t.Errorf("%s LoginSuccess has no format verb", lang) + } + + // GrantedScopes should contain %s + got = fmt.Sprintf(msg.GrantedScopes, "scope1 scope2") + if got == msg.GrantedScopes { + t.Errorf("%s GrantedScopes has no format verb", lang) + } + + // SummaryDomains should contain %s + got = fmt.Sprintf(msg.SummaryDomains, "calendar, task") + if got == msg.SummaryDomains { + t.Errorf("%s SummaryDomains has no format verb", lang) + } + + // SummaryPerm should contain %s + got = fmt.Sprintf(msg.SummaryPerm, "all") + if got == msg.SummaryPerm { + t.Errorf("%s SummaryPerm has no format verb", lang) + } + + // SummaryScopes should contain %d and %s + got = fmt.Sprintf(msg.SummaryScopes, 5, "a, b, c") + if got == msg.SummaryScopes { + t.Errorf("%s SummaryScopes has no format verb", lang) + } + } +} diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go new file mode 100644 index 00000000..06f9717e --- /dev/null +++ b/cmd/auth/login_test.go @@ -0,0 +1,292 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "sort" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestSuggestDomain_PrefixMatch(t *testing.T) { + known := map[string]bool{ + "calendar": true, + "task": true, + "drive": true, + "im": true, + } + + // Input is prefix of known domain + if s := suggestDomain("cal", known); s != "calendar" { + t.Errorf("expected 'calendar', got %q", s) + } + + // Known domain is prefix of input + if s := suggestDomain("calendar_extra", known); s != "calendar" { + t.Errorf("expected 'calendar', got %q", s) + } +} + +func TestSuggestDomain_NoMatch(t *testing.T) { + known := map[string]bool{ + "calendar": true, + "task": true, + } + + if s := suggestDomain("zzz", known); s != "" { + t.Errorf("expected empty suggestion, got %q", s) + } +} + +func TestSuggestDomain_ExactMatch(t *testing.T) { + known := map[string]bool{ + "calendar": true, + } + + // Exact match: input is prefix of known AND known is prefix of input + if s := suggestDomain("calendar", known); s != "calendar" { + t.Errorf("expected 'calendar', got %q", s) + } +} + +func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) { + // Empty AuthTypes defaults to ["user"] + sc := common.Shortcut{AuthTypes: nil} + if !shortcutSupportsIdentity(sc, "user") { + t.Error("expected default to support 'user'") + } + if shortcutSupportsIdentity(sc, "bot") { + t.Error("expected default to NOT support 'bot'") + } +} + +func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) { + sc := common.Shortcut{AuthTypes: []string{"user", "bot"}} + if !shortcutSupportsIdentity(sc, "user") { + t.Error("expected to support 'user'") + } + if !shortcutSupportsIdentity(sc, "bot") { + t.Error("expected to support 'bot'") + } + if shortcutSupportsIdentity(sc, "tenant") { + t.Error("expected to NOT support 'tenant'") + } +} + +func TestShortcutSupportsIdentity_BotOnly(t *testing.T) { + sc := common.Shortcut{AuthTypes: []string{"bot"}} + if shortcutSupportsIdentity(sc, "user") { + t.Error("expected bot-only to NOT support 'user'") + } + if !shortcutSupportsIdentity(sc, "bot") { + t.Error("expected bot-only to support 'bot'") + } +} + +func TestCompleteDomain(t *testing.T) { + projects := registry.ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + // Complete from empty prefix + completions := completeDomain("") + if len(completions) == 0 { + t.Fatal("expected completions for empty prefix") + } + // All completions should match from_meta projects + if len(completions) != len(projects) { + t.Errorf("expected %d completions, got %d", len(projects), len(completions)) + } + + // Complete with partial prefix + completions = completeDomain("cal") + for _, c := range completions { + if c != "calendar" && c[:3] != "cal" { + t.Errorf("unexpected completion %q for prefix 'cal'", c) + } + } +} + +func TestCompleteDomain_CommaSeparated(t *testing.T) { + projects := registry.ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + // After a comma, should complete the next segment + completions := completeDomain("calendar,") + for _, c := range completions { + if c[:9] != "calendar," { + t.Errorf("expected 'calendar,' prefix, got %q", c) + } + } +} + +func TestAllKnownDomains(t *testing.T) { + domains := allKnownDomains() + if len(domains) == 0 { + t.Fatal("expected non-empty known domains") + } + + // Should include from_meta projects + for _, p := range registry.ListFromMetaProjects() { + if !domains[p] { + t.Errorf("expected from_meta project %q in known domains", p) + } + } +} + +func TestSortedKnownDomains(t *testing.T) { + sorted := sortedKnownDomains() + if len(sorted) == 0 { + t.Fatal("expected non-empty sorted domains") + } + + if !sort.StringsAreSorted(sorted) { + t.Error("expected sorted result") + } + + // Should match allKnownDomains + known := allKnownDomains() + if len(sorted) != len(known) { + t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known)) + } +} + +func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) { + for _, name := range getShortcutOnlyDomainNames() { + zhDesc := registry.GetServiceDescription(name, "zh") + enDesc := registry.GetServiceDescription(name, "en") + if zhDesc == "" { + t.Errorf("missing zh description for shortcut-only domain %q", name) + } + if enDesc == "" { + t.Errorf("missing en description for shortcut-only domain %q", name) + } + } +} + +func TestCollectScopesForDomains(t *testing.T) { + projects := registry.ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + scopes := collectScopesForDomains([]string{"calendar"}, "user") + if len(scopes) == 0 { + t.Fatal("expected non-empty scopes for calendar domain") + } + + // Should be sorted + if !sort.StringsAreSorted(scopes) { + t.Error("expected sorted result") + } + + // Should include at least the API scopes + apiScopes := registry.CollectScopesForProjects([]string{"calendar"}, "user") + for _, s := range apiScopes { + found := false + for _, cs := range scopes { + if cs == s { + found = true + break + } + } + if !found { + t.Errorf("API scope %q missing from collectScopesForDomains result", s) + } + } +} + +func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) { + scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user") + if len(scopes) != 0 { + t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes)) + } +} + +func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) { + domains := getDomainMetadata("zh") + nameSet := make(map[string]bool) + for _, dm := range domains { + nameSet[dm.Name] = true + } + + // from_meta projects must be present + for _, p := range registry.ListFromMetaProjects() { + if !nameSet[p] { + t.Errorf("from_meta project %q missing from getDomainMetadata", p) + } + } +} + +func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) { + domains := getDomainMetadata("zh") + nameSet := make(map[string]bool) + for _, dm := range domains { + nameSet[dm.Name] = true + } + + for _, name := range getShortcutOnlyDomainNames() { + if !nameSet[name] { + t.Errorf("shortcut-only domain %q missing from getDomainMetadata", name) + } + } +} + +func TestGetDomainMetadata_Sorted(t *testing.T) { + domains := getDomainMetadata("zh") + for i := 1; i < len(domains); i++ { + if domains[i].Name < domains[i-1].Name { + t.Errorf("not sorted: %q before %q", domains[i-1].Name, domains[i].Name) + } + } +} + +func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) { + domains := getDomainMetadata("zh") + for _, dm := range domains { + if dm.Title == "" { + t.Errorf("domain %q has empty Title", dm.Name) + } + } +} + +func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu, + }) + // TestFactory has IsTerminal=false by default + opts := &LoginOptions{Factory: f, Ctx: context.Background()} + err := authLoginRun(opts) + if err == nil { + t.Fatal("expected error for non-terminal without flags") + } + // Should mention specifying scopes + msg := err.Error() + if !strings.Contains(msg, "scopes") { + t.Errorf("expected error to mention scopes, got: %s", msg) + } + // Stderr should contain background hint + stderrStr := stderr.String() + if !strings.Contains(stderrStr, "background") { + t.Errorf("expected stderr to mention background, got: %s", stderrStr) + } +} + +func TestGetDomainMetadata_ExcludesEvent(t *testing.T) { + domains := getDomainMetadata("zh") + for _, dm := range domains { + if dm.Name == "event" { + t.Error("event should not appear in interactive domain list") + } + } +} diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go new file mode 100644 index 00000000..4914120c --- /dev/null +++ b/cmd/auth/logout.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// LogoutOptions holds all inputs for auth logout. +type LogoutOptions struct { + Factory *cmdutil.Factory +} + +// NewCmdAuthLogout creates the auth logout subcommand. +func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Command { + opts := &LogoutOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "logout", + Short: "Log out (clear token)", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return authLogoutRun(opts) + }, + } + + return cmd +} + +func authLogoutRun(opts *LogoutOptions) error { + f := opts.Factory + + multi, _ := core.LoadMultiAppConfig() + if multi == nil || len(multi.Apps) == 0 { + fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.") + return nil + } + + app := &multi.Apps[0] + if len(app.Users) == 0 { + fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.") + return nil + } + + for _, user := range app.Users { + if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err) + } + } + app.Users = []core.AppUser{} + if err := core.SaveMultiAppConfig(multi); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + output.PrintSuccess(f.IOStreams.ErrOut, "Logged out") + return nil +} diff --git a/cmd/auth/scopes.go b/cmd/auth/scopes.go new file mode 100644 index 00000000..23f8ef81 --- /dev/null +++ b/cmd/auth/scopes.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" +) + +// ScopesOptions holds all inputs for auth scopes. +type ScopesOptions struct { + Factory *cmdutil.Factory + Ctx context.Context + Format string +} + +// NewCmdAuthScopes creates the auth scopes subcommand. +func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobra.Command { + opts := &ScopesOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "scopes", + Short: "Query scopes enabled for the app", + RunE: func(cmd *cobra.Command, args []string) error { + opts.Ctx = cmd.Context() + if runF != nil { + return runF(opts) + } + return authScopesRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") + + return cmd +} + +func authScopesRun(opts *ScopesOptions) error { + f := opts.Factory + + config, err := f.Config() + if err != nil { + return err + } + fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n") + appInfo, err := getAppInfo(opts.Ctx, f, config.AppID) + if err != nil { + return output.ErrWithHint(output.ExitAPI, "permission", + fmt.Sprintf("failed to get app scope info: %v", err), + "ensure the app has enabled the application:application:self_manage scope.") + } + if opts.Format == "pretty" { + fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID) + fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes)) + for _, s := range appInfo.UserScopes { + fmt.Fprintf(f.IOStreams.ErrOut, " • %s\n", s) + } + } else { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "appId": config.AppID, + "brand": config.Brand, + "tokenType": "user", + "userScopes": appInfo.UserScopes, + "count": len(appInfo.UserScopes), + }) + } + return nil +} diff --git a/cmd/auth/status.go b/cmd/auth/status.go new file mode 100644 index 00000000..55abfe58 --- /dev/null +++ b/cmd/auth/status.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "time" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// StatusOptions holds all inputs for auth status. +type StatusOptions struct { + Factory *cmdutil.Factory + Verify bool +} + +// NewCmdAuthStatus creates the auth status subcommand. +func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { + opts := &StatusOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "status", + Short: "View current auth status", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return authStatusRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)") + + return cmd +} + +func authStatusRun(opts *StatusOptions) error { + f := opts.Factory + + config, err := f.Config() + if err != nil { + return err + } + + defaultAs := config.DefaultAs + if defaultAs == "" { + defaultAs = "auto" + } + result := map[string]interface{}{ + "appId": config.AppID, + "brand": config.Brand, + "defaultAs": defaultAs, + } + + if config.UserOpenId == "" { + result["identity"] = "bot" + result["note"] = "No user logged in. Only bot (tenant) identity is available for API calls. Run `lark-cli auth login` to log in." + output.PrintJson(f.IOStreams.Out, result) + return nil + } + + stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId) + if stored == nil { + result["identity"] = "bot" + result["userName"] = config.UserName + result["userOpenId"] = config.UserOpenId + result["note"] = "Token does not exist or has been cleared. Only bot (tenant) identity is available. Re-login: lark-cli auth login" + output.PrintJson(f.IOStreams.Out, result) + return nil + } + + status := larkauth.TokenStatus(stored) + if status == "expired" { + result["identity"] = "bot" + result["note"] = "User token has expired. Only bot (tenant) identity is available. Re-login: lark-cli auth login" + } else { + result["identity"] = "user" + } + result["userName"] = config.UserName + result["userOpenId"] = config.UserOpenId + result["tokenStatus"] = status + result["scope"] = stored.Scope + result["expiresAt"] = time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339) + result["refreshExpiresAt"] = time.UnixMilli(stored.RefreshExpiresAt).Format(time.RFC3339) + result["grantedAt"] = time.UnixMilli(stored.GrantedAt).Format(time.RFC3339) + + // --verify: call the server to confirm token is actually usable. + if opts.Verify && status != "expired" { + verified, verifyErr := verifyTokenOnServer(f, config) + result["verified"] = verified + if verifyErr != "" { + result["verifyError"] = verifyErr + } + } + + output.PrintJson(f.IOStreams.Out, result) + return nil +} + +// verifyTokenOnServer obtains a valid access token (refreshing if needed) +// and calls /authen/v1/user_info to confirm the server accepts it. +// Returns (true, "") on success or (false, reason) on failure. +func verifyTokenOnServer(f *cmdutil.Factory, config *core.CliConfig) (bool, string) { + httpClient, err := f.HttpClient() + if err != nil { + return false, "failed to create HTTP client: " + err.Error() + } + + token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut)) + if err != nil { + return false, "token unusable: " + err.Error() + } + + sdk, err := f.LarkClient() + if err != nil { + return false, "failed to create SDK client: " + err.Error() + } + + if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil { + return false, "server rejected token: " + err.Error() + } + + return true, "" +} diff --git a/cmd/completion/completion.go b/cmd/completion/completion.go new file mode 100644 index 00000000..574365b7 --- /dev/null +++ b/cmd/completion/completion.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package completion + +import ( + "fmt" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdCompletion creates the completion command that generates shell completion scripts. +func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "completion ", + Short: "Generate shell completion scripts", + Long: "Generate shell completion scripts for bash, zsh, fish, or powershell.", + Hidden: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + root := cmd.Root() + out := f.IOStreams.Out + switch args[0] { + case "bash": + return root.GenBashCompletionV2(out, true) + case "zsh": + return root.GenZshCompletion(out) + case "fish": + return root.GenFishCompletion(out, true) + case "powershell": + return root.GenPowerShellCompletionWithDesc(out) + default: + return fmt.Errorf("unsupported shell: %s", args[0]) + } + }, + } + cmdutil.DisableAuthCheck(cmd) + return cmd +} diff --git a/cmd/config/config.go b/cmd/config/config.go new file mode 100644 index 00000000..055ecfda --- /dev/null +++ b/cmd/config/config.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/spf13/cobra" +) + +// NewCmdConfig creates the config command with subcommands. +func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Global CLI configuration management", + } + cmdutil.DisableAuthCheck(cmd) + + cmd.AddCommand(NewCmdConfigInit(f, nil)) + cmd.AddCommand(NewCmdConfigRemove(f, nil)) + cmd.AddCommand(NewCmdConfigShow(f, nil)) + cmd.AddCommand(NewCmdConfigDefaultAs(f)) + return cmd +} + +func parseBrand(value string) core.LarkBrand { + if value == "lark" { + return core.BrandLark + } + return core.BrandFeishu +} diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go new file mode 100644 index 00000000..65642781 --- /dev/null +++ b/cmd/config/config_test.go @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +func TestConfigInitCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + f.IOStreams.In = strings.NewReader("secret123\n") + + var gotOpts *ConfigInitOptions + cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--app-id", "cli_test", "--app-secret-stdin", "--brand", "lark"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.AppID != "cli_test" { + t.Errorf("expected AppID cli_test, got %s", gotOpts.AppID) + } + if !gotOpts.AppSecretStdin { + t.Error("expected AppSecretStdin=true") + } + if gotOpts.Brand != "lark" { + t.Errorf("expected Brand lark, got %s", gotOpts.Brand) + } +} + +func TestConfigShowCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *ConfigShowOptions + cmd := NewCmdConfigShow(f, func(opts *ConfigShowOptions) error { + gotOpts = opts + return nil + }) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Error("expected opts to be set") + } +} + +func TestConfigInitCmd_LangFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *ConfigInitOptions + cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { + gotOpts = opts + return nil + }) + f.IOStreams.In = strings.NewReader("y\n") + cmd.SetArgs([]string{"--app-id", "x", "--app-secret-stdin", "--lang", "en"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Lang != "en" { + t.Errorf("expected Lang en, got %s", gotOpts.Lang) + } + if !gotOpts.langExplicit { + t.Error("expected langExplicit=true when --lang is passed") + } +} + +func TestConfigInitCmd_LangDefault(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *ConfigInitOptions + cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error { + gotOpts = opts + return nil + }) + f.IOStreams.In = strings.NewReader("y\n") + cmd.SetArgs([]string{"--app-id", "x", "--app-secret-stdin"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Lang != "zh" { + t.Errorf("expected default Lang zh, got %s", gotOpts.Lang) + } + if gotOpts.langExplicit { + t.Error("expected langExplicit=false when --lang is not passed") + } +} + +func TestHasAnyNonInteractiveFlag(t *testing.T) { + tests := []struct { + name string + opts ConfigInitOptions + want bool + }{ + {"empty", ConfigInitOptions{}, false}, + {"new", ConfigInitOptions{New: true}, true}, + {"app-id", ConfigInitOptions{AppID: "x"}, true}, + {"app-secret-stdin", ConfigInitOptions{AppSecretStdin: true}, true}, + {"app-id+secret-stdin", ConfigInitOptions{AppID: "x", AppSecretStdin: true}, true}, + {"lang-only", ConfigInitOptions{Lang: "en"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.opts.hasAnyNonInteractiveFlag() + if got != tt.want { + t.Errorf("hasAnyNonInteractiveFlag() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + // TestFactory has IsTerminal=false by default + opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Lang: "zh"} + err := configInitRun(opts) + if err == nil { + t.Fatal("expected error for non-terminal without flags") + } + msg := err.Error() + if !strings.Contains(msg, "--new") { + t.Errorf("expected error to mention --new, got: %s", msg) + } + if !strings.Contains(msg, "terminal") { + t.Errorf("expected error to mention terminal, got: %s", msg) + } +} + +func TestConfigRemoveCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *ConfigRemoveOptions + cmd := NewCmdConfigRemove(f, func(opts *ConfigRemoveOptions) error { + gotOpts = opts + return nil + }) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Fatal("expected opts to be set") + } + if gotOpts.Factory != f { + t.Fatal("expected factory to be preserved in options") + } +} diff --git a/cmd/config/default_as.go b/cmd/config/default_as.go new file mode 100644 index 00000000..0600de5d --- /dev/null +++ b/cmd/config/default_as.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// NewCmdConfigDefaultAs creates the "config default-as" subcommand. +func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "default-as [user|bot|auto]", + Short: "View or set default identity type", + Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + multi, err := core.LoadMultiAppConfig() + if err != nil { + return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init") + } + + if len(args) == 0 { + current := multi.Apps[0].DefaultAs + if current == "" { + current = "auto" + } + fmt.Fprintf(f.IOStreams.Out, "default-as: %s\n", current) + return nil + } + + value := args[0] + if value != "user" && value != "bot" && value != "auto" { + return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value) + } + + multi.Apps[0].DefaultAs = value + if err := core.SaveMultiAppConfig(multi); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value) + return nil + }, + } + return cmd +} diff --git a/cmd/config/init.go b/cmd/config/init.go new file mode 100644 index 00000000..8ddff761 --- /dev/null +++ b/cmd/config/init.go @@ -0,0 +1,305 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// ConfigInitOptions holds all inputs for config init. +type ConfigInitOptions struct { + Factory *cmdutil.Factory + Ctx context.Context + AppID string + appSecret string // internal only; populated from stdin, never from a CLI flag + AppSecretStdin bool // read app-secret from stdin (avoids process list exposure) + Brand string + New bool + Lang string + langExplicit bool // true when --lang was explicitly passed +} + +// NewCmdConfigInit creates the config init subcommand. +func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command { + opts := &ConfigInitOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize configuration (app-id / app-secret-stdin / brand)", + Long: `Initialize configuration (app-id / app-secret-stdin / brand). + +For AI agents: use --new to create a new app. The command blocks until the user +completes setup in the browser. Run it in the background and retrieve the +verification URL from its output.`, + RunE: func(cmd *cobra.Command, args []string) error { + opts.Ctx = cmd.Context() + opts.langExplicit = cmd.Flags().Changed("lang") + if runF != nil { + return runF(opts) + } + return configInitRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)") + cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)") + cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure") + cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)") + cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)") + + return cmd +} + +// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set. +func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool { + return o.New || o.AppID != "" || o.AppSecretStdin +} + +// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID. +func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipAppID string) { + if existing == nil { + return + } + for _, app := range existing.Apps { + if app.AppId == skipAppID { + continue + } + core.RemoveSecretStore(app.AppSecret, f.Keychain) + for _, user := range app.Users { + auth.RemoveStoredToken(app.AppId, user.UserOpenId) + } + } +} + +// saveAsOnlyApp overwrites config.json with a single-app config. +func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error { + config := &core.MultiAppConfig{ + Apps: []core.AppConfig{{ + AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{}, + }}, + } + return core.SaveMultiAppConfig(config) +} + +func configInitRun(opts *ConfigInitOptions) error { + f := opts.Factory + + // Read secret from stdin if --app-secret-stdin is set + if opts.AppSecretStdin { + scanner := bufio.NewScanner(f.IOStreams.In) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return output.ErrValidation("failed to read secret from stdin: %v", err) + } + return output.ErrValidation("stdin is empty, expected app secret") + } + opts.appSecret = strings.TrimSpace(scanner.Text()) + if opts.appSecret == "" { + return output.ErrValidation("app secret read from stdin is empty") + } + } + + existing, err := core.LoadMultiAppConfig() + if err != nil { + existing = nil // treat as empty + } + + // Mode 1: Non-interactive + if opts.AppID != "" && opts.appSecret != "" { + brand := parseBrand(opts.Brand) + secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } + cleanupOldConfig(existing, f, opts.AppID) + if err := saveAsOnlyApp(opts.AppID, secret, brand, opts.Lang); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand}) + return nil + } + + // For interactive modes, prompt language selection if --lang was not explicitly set + if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() { + savedLang := "" + if existing != nil && len(existing.Apps) > 0 { + savedLang = existing.Apps[0].Lang + } + lang, err := promptLangSelection(savedLang) + if err != nil { + if err == huh.ErrUserAborted { + return output.ErrBare(1) + } + return err + } + opts.Lang = lang + } + + msg := getInitMsg(opts.Lang) + + // Mode 3: Create new app directly (--new) + if opts.New { + result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg) + if err != nil { + return err + } + if result == nil { + return output.ErrValidation("app creation returned no result") + } + existing, _ := core.LoadMultiAppConfig() + secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } + cleanupOldConfig(existing, f, result.AppID) + if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) + return nil + } + + // Mode 4: Interactive TUI (terminal) + if !opts.hasAnyNonInteractiveFlag() && f.IOStreams.IsTerminal { + result, err := runInteractiveConfigInit(opts.Ctx, f, msg) + if err != nil { + return err + } + if result == nil { + return output.ErrValidation("App ID and App Secret cannot be empty") + } + + existing, _ := core.LoadMultiAppConfig() + + if result.AppSecret != "" { + // New secret provided (either from "create" or "existing" with input) + secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } + cleanupOldConfig(existing, f, result.AppID) + if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + } else if result.Mode == "existing" && result.AppID != "" { + // Existing app with unchanged secret — update app ID and brand only + if existing != nil && len(existing.Apps) > 0 { + existing.Apps[0].AppId = result.AppID + existing.Apps[0].Brand = result.Brand + existing.Apps[0].Lang = opts.Lang + if err := core.SaveMultiAppConfig(existing); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + } else { + return output.ErrValidation("App Secret cannot be empty for new configuration") + } + } else { + return output.ErrValidation("App ID and App Secret cannot be empty") + } + + if result.Mode == "existing" { + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID)) + } + return nil + } + + // Non-terminal: cannot run interactive mode, guide user to --new + if !f.IOStreams.IsTerminal { + return output.ErrValidation("config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.") + } + + // Mode 5: Legacy interactive (readline fallback) + firstApp := (*core.AppConfig)(nil) + if existing != nil && len(existing.Apps) > 0 { + firstApp = &existing.Apps[0] + } + + reader := bufio.NewReader(f.IOStreams.In) + readLine := func(prompt string) (string, error) { + fmt.Fprintf(f.IOStreams.ErrOut, "%s: ", prompt) + line, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return "", fmt.Errorf("failed to read input: %w", err) + } + if err == io.EOF && strings.TrimSpace(line) == "" { + return "", fmt.Errorf("input terminated unexpectedly (EOF)") + } + return strings.TrimSpace(line), nil + } + + prompt := "App ID" + if firstApp != nil && firstApp.AppId != "" { + prompt += fmt.Sprintf(" [%s]", firstApp.AppId) + } + appIdInput, err := readLine(prompt) + if err != nil { + return output.ErrValidation("%s", err) + } + + prompt = "App Secret" + if firstApp != nil && !firstApp.AppSecret.IsZero() { + prompt += " [****]" + } + appSecretInput, err := readLine(prompt) + if err != nil { + return output.ErrValidation("%s", err) + } + + prompt = "Brand (lark/feishu)" + if firstApp != nil && firstApp.Brand != "" { + prompt += fmt.Sprintf(" [%s]", firstApp.Brand) + } else { + prompt += " [feishu]" + } + brandInput, err := readLine(prompt) + if err != nil { + return output.ErrValidation("%s", err) + } + + resolvedAppId := appIdInput + if resolvedAppId == "" && firstApp != nil { + resolvedAppId = firstApp.AppId + } + var resolvedSecret core.SecretInput + if appSecretInput != "" { + resolvedSecret = core.PlainSecret(appSecretInput) + } else if firstApp != nil { + resolvedSecret = firstApp.AppSecret + } + resolvedBrand := brandInput + if resolvedBrand == "" && firstApp != nil { + resolvedBrand = string(firstApp.Brand) + } + if resolvedBrand == "" { + resolvedBrand = "feishu" + } + + if resolvedAppId == "" || resolvedSecret.IsZero() { + return output.ErrValidation("App ID and App Secret cannot be empty") + } + + storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain) + if err != nil { + return output.Errorf(output.ExitInternal, "internal", "%v", err) + } + cleanupOldConfig(existing, f, resolvedAppId) + if err := saveAsOnlyApp(resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) + return nil +} diff --git a/cmd/config/init_interactive.go b/cmd/config/init_interactive.go new file mode 100644 index 00000000..0172079d --- /dev/null +++ b/cmd/config/init_interactive.go @@ -0,0 +1,227 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "context" + "fmt" + "net/http" + + "github.com/charmbracelet/huh" + "github.com/larksuite/cli/internal/build" + qrcode "github.com/skip2/go-qrcode" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// configInitResult holds the result of the interactive config init flow. +type configInitResult struct { + Mode string // "create" or "existing" + Brand core.LarkBrand + AppID string + AppSecret string +} + +// runInteractiveConfigInit shows an interactive TUI for config init. +func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) { + // Phase 1: Choose mode + var mode string + form1 := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(msg.SelectAction). + Options( + huh.NewOption(msg.CreateNewApp, "create"), + huh.NewOption(msg.ConfigExistingApp, "existing"), + ). + Value(&mode), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form1.Run(); err != nil { + if err == huh.ErrUserAborted { + return nil, output.ErrBare(1) + } + return nil, err + } + + if mode == "existing" { + return runExistingAppForm(f, msg) + } + + return runCreateAppFlow(ctx, f, "", msg) +} + +// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand. +func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) { + // Load existing config for defaults + existing, _ := core.LoadMultiAppConfig() + var firstApp *core.AppConfig + if existing != nil && len(existing.Apps) > 0 { + firstApp = &existing.Apps[0] + } + + var appID, appSecret, brand string + + appIDInput := huh.NewInput(). + Title("App ID"). + Value(&appID) + if firstApp != nil && firstApp.AppId != "" { + appIDInput = appIDInput.Placeholder(firstApp.AppId) + } else { + appIDInput = appIDInput.Placeholder("cli_xxxx") + } + + appSecretInput := huh.NewInput(). + Title("App Secret"). + EchoMode(huh.EchoModePassword). + Value(&appSecret) + if firstApp != nil && !firstApp.AppSecret.IsZero() { + appSecretInput = appSecretInput.Placeholder("****") + } else { + appSecretInput = appSecretInput.Placeholder("xxxx") + } + + brand = "feishu" + if firstApp != nil && firstApp.Brand != "" { + brand = string(firstApp.Brand) + } + + form := huh.NewForm( + huh.NewGroup( + appIDInput, + appSecretInput, + huh.NewSelect[string](). + Title(msg.Platform). + Options( + huh.NewOption(msg.Feishu, "feishu"), + huh.NewOption("Lark", "lark"), + ). + Value(&brand), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form.Run(); err != nil { + if err == huh.ErrUserAborted { + return nil, output.ErrBare(1) + } + return nil, err + } + + // Resolve defaults + if appID == "" && firstApp != nil { + appID = firstApp.AppId + } + if appSecret == "" && firstApp != nil && !firstApp.AppSecret.IsZero() { + // Keep existing secret - caller will handle + return &configInitResult{ + Mode: "existing", + Brand: parseBrand(brand), + AppID: appID, + }, nil + } + + if appID == "" || appSecret == "" { + return nil, output.ErrValidation("App ID and App Secret cannot be empty") + } + + return &configInitResult{ + Mode: "existing", + Brand: parseBrand(brand), + AppID: appID, + AppSecret: appSecret, + }, nil +} + +// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow. +// If brandOverride is non-empty, skip the interactive brand selection. +func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) { + var larkBrand core.LarkBrand + if brandOverride != "" { + larkBrand = brandOverride + } else { + // Phase 2: Brand selection + var brand string + form2 := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(msg.SelectPlatform). + Options( + huh.NewOption(msg.Feishu, "feishu"), + huh.NewOption("Lark", "lark"), + ). + Value(&brand), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form2.Run(); err != nil { + if err == huh.ErrUserAborted { + return nil, output.ErrBare(1) + } + return nil, err + } + larkBrand = parseBrand(brand) + } + + // Step 1: Request app registration (begin) + httpClient := &http.Client{} + authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut) + if err != nil { + return nil, output.ErrAuth("app registration failed: %v", err) + } + + // Step 2: Build and display verification URL + QR code + verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version) + + // Show QR code in terminal + qr, qrErr := qrcode.New(verificationURL, qrcode.Medium) + if qrErr == nil { + fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false)) + } + + fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink) + fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL) + + // Step 3: Poll for result + fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan) + result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) + if err != nil { + return nil, output.ErrAuth("%v", err) + } + + // Step 4: Handle Lark brand special case + // If tenant_brand=lark and no client_secret, retry with lark brand endpoint + if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" { + // fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant) + result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut) + if err != nil { + return nil, output.ErrAuth("lark endpoint retry failed: %v", err) + } + } + + if result.ClientID == "" || result.ClientSecret == "" { + return nil, output.ErrAuth("app registration succeeded but missing client_id or client_secret") + } + + // Determine final brand from response + finalBrand := larkBrand + if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" { + finalBrand = core.BrandLark + } else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" { + finalBrand = core.BrandFeishu + } + + fmt.Fprintln(f.IOStreams.ErrOut) + output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID)) + + return &configInitResult{ + Mode: "create", + Brand: finalBrand, + AppID: result.ClientID, + AppSecret: result.ClientSecret, + }, nil +} diff --git a/cmd/config/init_messages.go b/cmd/config/init_messages.go new file mode 100644 index 00000000..54e2dbbe --- /dev/null +++ b/cmd/config/init_messages.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "github.com/charmbracelet/huh" + + "github.com/larksuite/cli/internal/cmdutil" +) + +type initMsg struct { + SelectAction string + CreateNewApp string + ConfigExistingApp string + Platform string + SelectPlatform string + Feishu string + ScanOrOpenLink string + WaitingForScan string + DetectedLarkTenant string + AppCreated string + ConfigSaved string +} + +var initMsgZh = &initMsg{ + SelectAction: "选择操作", + CreateNewApp: "一键配置应用 (推荐) ", + ConfigExistingApp: "手动输入应用凭证", + Platform: "平台", + SelectPlatform: "选择平台", + Feishu: "飞书", + ScanOrOpenLink: "\n打开以下链接配置应用:\n\n", + WaitingForScan: "等待配置应用...", + DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...", + AppCreated: "应用配置成功! App ID: %s", + ConfigSaved: "应用配置成功! App ID: %s", +} + +var initMsgEn = &initMsg{ + SelectAction: "Select action", + CreateNewApp: "Set up your app with one click (Recommended)", + ConfigExistingApp: "Enter app credentials yourself", + Platform: "Platform", + SelectPlatform: "Select platform", + Feishu: "Feishu", + ScanOrOpenLink: "\nOpen the link below to configure app:\n\n", + WaitingForScan: "Waiting for app configuration...", + DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...", + AppCreated: "App configured! App ID: %s", + ConfigSaved: "App configured! App ID: %s", +} + +func getInitMsg(lang string) *initMsg { + if lang == "en" { + return initMsgEn + } + return initMsgZh +} + +// promptLangSelection shows an interactive language picker and returns the chosen lang code. +// savedLang is used as the pre-selected default (from existing config). +func promptLangSelection(savedLang string) (string, error) { + lang := savedLang + if lang != "en" { + lang = "zh" + } + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Language / 语言"). + Options( + huh.NewOption("中文", "zh"), + huh.NewOption("English", "en"), + ). + Value(&lang), + ), + ).WithTheme(cmdutil.ThemeFeishu()) + + if err := form.Run(); err != nil { + return "", err + } + return lang, nil +} diff --git a/cmd/config/init_messages_test.go b/cmd/config/init_messages_test.go new file mode 100644 index 00000000..0e2ebe56 --- /dev/null +++ b/cmd/config/init_messages_test.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + "testing" +) + +func TestGetInitMsg_Zh(t *testing.T) { + msg := getInitMsg("zh") + if msg != initMsgZh { + t.Error("expected zh message set") + } + if msg.SelectAction != "选择操作" { + t.Errorf("unexpected SelectAction: %s", msg.SelectAction) + } +} + +func TestGetInitMsg_En(t *testing.T) { + msg := getInitMsg("en") + if msg != initMsgEn { + t.Error("expected en message set") + } + if msg.SelectAction != "Select action" { + t.Errorf("unexpected SelectAction: %s", msg.SelectAction) + } +} + +func TestGetInitMsg_DefaultsToZh(t *testing.T) { + for _, lang := range []string{"", "fr", "ja", "unknown"} { + msg := getInitMsg(lang) + if msg != initMsgZh { + t.Errorf("getInitMsg(%q) should default to zh", lang) + } + } +} + +func TestInitMsgZh_AllFieldsNonEmpty(t *testing.T) { + assertAllFieldsNonEmpty(t, initMsgZh, "zh") +} + +func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) { + assertAllFieldsNonEmpty(t, initMsgEn, "en") +} + +func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) { + t.Helper() + fields := map[string]string{ + "SelectAction": msg.SelectAction, + "CreateNewApp": msg.CreateNewApp, + "ConfigExistingApp": msg.ConfigExistingApp, + "Platform": msg.Platform, + "SelectPlatform": msg.SelectPlatform, + "Feishu": msg.Feishu, + "ScanOrOpenLink": msg.ScanOrOpenLink, + "WaitingForScan": msg.WaitingForScan, + "DetectedLarkTenant": msg.DetectedLarkTenant, + "AppCreated": msg.AppCreated, + "ConfigSaved": msg.ConfigSaved, + } + for name, val := range fields { + if val == "" { + t.Errorf("%s.%s is empty", label, name) + } + } +} + +func TestInitMsg_FormatStrings(t *testing.T) { + for _, lang := range []string{"zh", "en"} { + msg := getInitMsg(lang) + // AppCreated and ConfigSaved should contain %s for App ID + got := fmt.Sprintf(msg.AppCreated, "cli_test123") + if got == msg.AppCreated { + t.Errorf("%s AppCreated has no format verb", lang) + } + got = fmt.Sprintf(msg.ConfigSaved, "cli_test123") + if got == msg.ConfigSaved { + t.Errorf("%s ConfigSaved has no format verb", lang) + } + } +} diff --git a/cmd/config/remove.go b/cmd/config/remove.go new file mode 100644 index 00000000..0d7835e8 --- /dev/null +++ b/cmd/config/remove.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// ConfigRemoveOptions holds all inputs for config remove. +type ConfigRemoveOptions struct { + Factory *cmdutil.Factory +} + +// NewCmdConfigRemove creates the config remove subcommand. +func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) error) *cobra.Command { + opts := &ConfigRemoveOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "remove", + Short: "Remove app configuration (clears all tokens and config)", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return configRemoveRun(opts) + }, + } + + return cmd +} + +func configRemoveRun(opts *ConfigRemoveOptions) error { + f := opts.Factory + + config, err := core.LoadMultiAppConfig() + if err != nil || config == nil || len(config.Apps) == 0 { + return output.ErrValidation("not configured yet") + } + + // Clean up keychain entries for all apps + for _, app := range config.Apps { + core.RemoveSecretStore(app.AppSecret, f.Keychain) + for _, user := range app.Users { + auth.RemoveStoredToken(app.AppId, user.UserOpenId) + } + } + + // Save empty config + empty := &core.MultiAppConfig{Apps: []core.AppConfig{}} + if err := core.SaveMultiAppConfig(empty); err != nil { + return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err) + } + output.PrintSuccess(f.IOStreams.ErrOut, "Configuration removed") + userCount := 0 + for _, app := range config.Apps { + userCount += len(app.Users) + } + if userCount > 0 { + fmt.Fprintf(f.IOStreams.ErrOut, "Cleared tokens for %d users\n", userCount) + } + return nil +} diff --git a/cmd/config/show.go b/cmd/config/show.go new file mode 100644 index 00000000..cdee9e34 --- /dev/null +++ b/cmd/config/show.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package config + +import ( + "fmt" + "strings" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// ConfigShowOptions holds all inputs for config show. +type ConfigShowOptions struct { + Factory *cmdutil.Factory +} + +// NewCmdConfigShow creates the config show subcommand. +func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *cobra.Command { + opts := &ConfigShowOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "show", + Short: "Show current configuration", + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + return configShowRun(opts) + }, + } + + return cmd +} + +func configShowRun(opts *ConfigShowOptions) error { + f := opts.Factory + + config, err := core.LoadMultiAppConfig() + if err != nil || config == nil || len(config.Apps) == 0 { + fmt.Fprintf(f.IOStreams.ErrOut, "Not configured yet. Config file path: %s\n", core.GetConfigPath()) + fmt.Fprintln(f.IOStreams.ErrOut, "Run `lark-cli config init` to initialize.") + return nil + } + app := config.Apps[0] + users := "(no logged-in users)" + if len(app.Users) > 0 { + var userStrs []string + for _, u := range app.Users { + userStrs = append(userStrs, fmt.Sprintf("%s (%s)", u.UserName, u.UserOpenId)) + } + users = strings.Join(userStrs, ", ") + } + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "appId": app.AppId, + "appSecret": "****", + "brand": app.Brand, + "lang": app.Lang, + "users": users, + }) + fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath()) + return nil +} diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go new file mode 100644 index 00000000..6edde0a5 --- /dev/null +++ b/cmd/doctor/doctor.go @@ -0,0 +1,235 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doctor + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/spf13/cobra" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// DoctorOptions holds inputs for the doctor command. +type DoctorOptions struct { + Factory *cmdutil.Factory + Ctx context.Context + Offline bool +} + +// NewCmdDoctor creates the doctor command. +func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command { + opts := &DoctorOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "doctor", + Short: "CLI health check: config, auth, and connectivity", + RunE: func(cmd *cobra.Command, args []string) error { + opts.Ctx = cmd.Context() + return doctorRun(opts) + }, + } + cmdutil.DisableAuthCheck(cmd) + cmd.Flags().BoolVar(&opts.Offline, "offline", false, "skip network checks (only verify local state)") + + return cmd +} + +// checkResult represents one diagnostic check. +type checkResult struct { + Name string `json:"name"` + Status string `json:"status"` // "pass", "fail", "skip" + Message string `json:"message"` + Hint string `json:"hint,omitempty"` +} + +func pass(name, msg string) checkResult { + return checkResult{Name: name, Status: "pass", Message: msg} +} + +func fail(name, msg, hint string) checkResult { + return checkResult{Name: name, Status: "fail", Message: msg, Hint: hint} +} + +func skip(name, msg string) checkResult { + return checkResult{Name: name, Status: "skip", Message: msg} +} + +func doctorRun(opts *DoctorOptions) error { + f := opts.Factory + var checks []checkResult + + // ── 1. Config file ── + _, err := core.LoadMultiAppConfig() + if err != nil { + checks = append(checks, fail("config_file", err.Error(), "run: lark-cli config init")) + return finishDoctor(f, checks) + } + checks = append(checks, pass("config_file", "config.json found")) + + // ── 2. App resolved ── + cfg, err := f.Config() + if err != nil { + hint := "" + var cfgErr *core.ConfigError + if errors.As(err, &cfgErr) { + hint = cfgErr.Hint + } + checks = append(checks, fail("app_resolved", err.Error(), hint)) + return finishDoctor(f, checks) + } + checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand))) + + ep := core.ResolveEndpoints(cfg.Brand) + + // ── 3. Token exists ── + if cfg.UserOpenId == "" { + checks = append(checks, fail("token_exists", "no user logged in", "run: lark-cli auth login --help")) + checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) + return finishDoctor(f, checks) + } + stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId) + if stored == nil { + checks = append(checks, fail("token_exists", "no token in keychain for "+cfg.UserOpenId, "run: lark-cli auth login --help")) + checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) + return finishDoctor(f, checks) + } + checks = append(checks, pass("token_exists", fmt.Sprintf("token found for %s (%s)", cfg.UserName, cfg.UserOpenId))) + + // ── 4. Token local validity ── + status := larkauth.TokenStatus(stored) + switch status { + case "valid": + checks = append(checks, pass("token_local", "token valid, expires "+time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339))) + case "needs_refresh": + checks = append(checks, pass("token_local", "token needs refresh (will auto-refresh on next call)")) + default: // expired + checks = append(checks, fail("token_local", "token expired", "run: lark-cli auth login --help")) + checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) + return finishDoctor(f, checks) + } + + // ── 5. Token server verification ── + if opts.Offline { + checks = append(checks, skip("token_verified", "skipped (--offline)")) + } else { + httpClient := mustHTTPClient(f) + token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut)) + if err != nil { + checks = append(checks, fail("token_verified", "cannot obtain valid token: "+err.Error(), "run: lark-cli auth login --help")) + } else { + sdk, err := f.LarkClient() + if err != nil { + checks = append(checks, fail("token_verified", "SDK init failed: "+err.Error(), "")) + } else if err := larkauth.VerifyUserToken(opts.Ctx, sdk, token); err != nil { + checks = append(checks, fail("token_verified", "server rejected token: "+err.Error(), "run: lark-cli auth login --help")) + } else { + checks = append(checks, pass("token_verified", "server confirmed token is valid")) + } + } + } + + // ── 6 & 7. Endpoint reachability ── + checks = append(checks, networkChecks(opts.Ctx, opts, ep)...) + + return finishDoctor(f, checks) +} + +// networkChecks probes Open API and MCP endpoints concurrently. +func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult { + if opts.Offline { + return []checkResult{ + skip("endpoint_open", "skipped (--offline)"), + skip("endpoint_mcp", "skipped (--offline)"), + } + } + + httpClient := &http.Client{} + mcpURL := ep.MCP + "/mcp" + + type probeResult struct { + name string + url string + err error + } + + var wg sync.WaitGroup + results := make([]probeResult, 2) + + wg.Add(2) + go func() { + defer wg.Done() + defer func() { recover() }() + results[0] = probeResult{"endpoint_open", ep.Open, probeEndpoint(ctx, httpClient, ep.Open)} + }() + go func() { + defer wg.Done() + defer func() { recover() }() + results[1] = probeResult{"endpoint_mcp", mcpURL, probeEndpoint(ctx, httpClient, mcpURL)} + }() + wg.Wait() + + var checks []checkResult + for _, r := range results { + if r.err != nil { + checks = append(checks, fail(r.name, fmt.Sprintf("%s unreachable: %s", r.url, r.err), "check network or proxy settings")) + } else { + checks = append(checks, pass(r.name, r.url+" reachable")) + } + } + return checks +} + +// probeEndpoint sends a HEAD request to check reachability. +func probeEndpoint(ctx context.Context, client *http.Client, url string) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +// mustHTTPClient returns f.HttpClient() or a default client. +func mustHTTPClient(f *cmdutil.Factory) *http.Client { + c, err := f.HttpClient() + if err != nil { + return &http.Client{Timeout: 30 * time.Second} + } + return c +} + +func finishDoctor(f *cmdutil.Factory, checks []checkResult) error { + allOK := true + for _, c := range checks { + if c.Status == "fail" { + allOK = false + break + } + } + + result := map[string]interface{}{ + "ok": allOK, + "checks": checks, + } + output.PrintJson(f.IOStreams.Out, result) + if !allOK { + return output.ErrBare(1) + } + return nil +} diff --git a/cmd/doctor/doctor_test.go b/cmd/doctor/doctor_test.go new file mode 100644 index 00000000..5ffd7709 --- /dev/null +++ b/cmd/doctor/doctor_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doctor + +import ( + "context" + "encoding/json" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +func TestNewCmdDoctor_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdDoctor(f) + cmd.SetArgs([]string{"--offline"}) + + // We only test flag parsing; skip actual execution by intercepting RunE. + var gotOffline bool + origRunE := cmd.RunE + cmd.RunE = func(cmd2 *cobra.Command, args []string) error { + v, _ := cmd2.Flags().GetBool("offline") + gotOffline = v + return nil + } + _ = origRunE + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !gotOffline { + t.Error("expected --offline to be true") + } +} + +func TestFinishDoctor(t *testing.T) { + t.Run("all pass returns nil", func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + checks := []checkResult{ + pass("check1", "ok"), + skip("check2", "skipped"), + } + err := finishDoctor(f, checks) + if err != nil { + t.Fatalf("expected nil, got %v", err) + } + + var result struct { + OK bool `json:"ok"` + } + json.Unmarshal(stdout.Bytes(), &result) + if !result.OK { + t.Error("expected ok=true") + } + }) + + t.Run("any fail returns error", func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + checks := []checkResult{ + pass("check1", "ok"), + fail("check2", "bad", "fix it"), + } + err := finishDoctor(f, checks) + if err == nil { + t.Fatal("expected error, got nil") + } + + var result struct { + OK bool `json:"ok"` + } + json.Unmarshal(stdout.Bytes(), &result) + if result.OK { + t.Error("expected ok=false") + } + }) +} + +func TestNetworkChecks_Offline(t *testing.T) { + ep := core.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"} + opts := &DoctorOptions{Ctx: context.Background(), Offline: true} + checks := networkChecks(opts.Ctx, opts, ep) + if len(checks) != 2 { + t.Fatalf("expected 2 checks, got %d", len(checks)) + } + for _, c := range checks { + if c.Status != "skip" { + t.Errorf("expected skip, got %s for %s", c.Status, c.Name) + } + } +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 00000000..6cf9f624 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,307 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "strconv" + + "github.com/larksuite/cli/cmd/api" + "github.com/larksuite/cli/cmd/auth" + "github.com/larksuite/cli/cmd/completion" + cmdconfig "github.com/larksuite/cli/cmd/config" + "github.com/larksuite/cli/cmd/doctor" + "github.com/larksuite/cli/cmd/schema" + "github.com/larksuite/cli/cmd/service" + internalauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/shortcuts" + "github.com/spf13/cobra" +) + +const rootLong = `lark-cli — Lark/Feishu CLI tool. + +USAGE: + lark-cli [subcommand] [method] [options] + lark-cli api [--params ] [--data ] + lark-cli schema [--format pretty] + +EXAMPLES: + # View upcoming events + lark-cli calendar +agenda + + # List calendar events + lark-cli calendar events list --params '{"calendar_id":"primary"}' + + # Search users + lark-cli contact +search-user --query "John" + + # Generic API call + lark-cli api GET /open-apis/calendar/v4/calendars + +FLAGS: + --params URL/query parameters JSON + --data request body JSON (POST/PATCH/PUT/DELETE) + --as identity type: user | bot | auto (default: auto) + --format output format: json (default) | ndjson | table | csv | pretty + --page-all automatically paginate through all pages + --page-size page size (0 = use API default) + --page-limit max pages to fetch with --page-all (default: 10, 0 for unlimited) + --page-delay delay in ms between pages (default: 200, only with --page-all) + -o, --output output file path for binary responses + --dry-run print request without executing + +AI AGENT SKILLS: + lark-cli pairs with AI agent skills (Claude Code, etc.) that + teach the agent Lark API patterns, best practices, and workflows. + + Install all skills: + npx skills add larksuite/cli --all -y + + Or pick specific domains: + npx skills add larksuite/cli -s lark-calendar -y + npx skills add larksuite/cli -s lark-im -y + + Learn more: https://github.com/larksuite/cli#install-ai-agent-skills + +COMMUNITY: + GitHub: https://github.com/larksuite/cli + Issues: https://github.com/larksuite/cli/issues + Docs: https://open.feishu.cn/document/ + +More help: lark-cli --help` + +// Execute runs the root command and returns the process exit code. +func Execute() int { + f := cmdutil.NewDefault() + + rootCmd := &cobra.Command{ + Use: "lark-cli", + Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls", + Long: rootLong, + Version: build.Version, + } + installTipsHelpFunc(rootCmd) + rootCmd.SilenceErrors = true + rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + cmd.SilenceUsage = true + } + + rootCmd.AddCommand(cmdconfig.NewCmdConfig(f)) + rootCmd.AddCommand(auth.NewCmdAuth(f)) + rootCmd.AddCommand(doctor.NewCmdDoctor(f)) + rootCmd.AddCommand(api.NewCmdApi(f, nil)) + rootCmd.AddCommand(schema.NewCmdSchema(f, nil)) + rootCmd.AddCommand(completion.NewCmdCompletion(f)) + service.RegisterServiceCommands(rootCmd, f) + shortcuts.RegisterShortcuts(rootCmd, f) + + if err := rootCmd.Execute(); err != nil { + return handleRootError(f, err) + } + return 0 +} + +// handleRootError dispatches a command error to the appropriate handler +// and returns the process exit code. +func handleRootError(f *cmdutil.Factory, err error) int { + errOut := f.IOStreams.ErrOut + + // SecurityPolicyError uses a custom envelope format (string codes, challenge_url, retryable) + // that differs from the standard ErrDetail, so it's handled separately. + var spErr *internalauth.SecurityPolicyError + if errors.As(err, &spErr) { + writeSecurityPolicyError(errOut, spErr) + return 1 + } + + // All other structured errors normalize to ExitError. + if exitErr := asExitError(err); exitErr != nil { + if exitErr.Raw { + // Raw errors (e.g. from `api` command) already printed the full API + // response to stdout; skip enrichment and duplicate stderr envelope. + return exitErr.Code + } + enrichPermissionError(f, exitErr) + output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity)) + return exitErr.Code + } + + // Cobra errors (required flags, unknown commands, etc.) + fmt.Fprintln(errOut, "Error:", err) + return 1 +} + +// asExitError converts known structured error types to *output.ExitError. +// Returns nil for unrecognized errors (e.g. cobra flag errors). +func asExitError(err error) *output.ExitError { + var cfgErr *core.ConfigError + if errors.As(err, &cfgErr) { + return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint) + } + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return exitErr + } + return nil +} + +// writeSecurityPolicyError writes the security-policy-specific JSON envelope to w. +// This format intentionally differs from the standard ErrDetail envelope: +// it uses string codes ("challenge_required"/"access_denied") and extra fields +// (retryable, challenge_url) for machine-readable policy error handling. +func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyError) { + var codeStr string + switch spErr.Code { + case internalauth.LarkErrBlockByPolicyTryAuth: + codeStr = "challenge_required" + case internalauth.LarkErrBlockByPolicy: + codeStr = "access_denied" + default: + codeStr = strconv.Itoa(spErr.Code) + } + + errData := map[string]interface{}{ + "type": "auth_error", + "code": codeStr, + "message": spErr.Message, + "retryable": false, + } + if spErr.ChallengeURL != "" { + errData["challenge_url"] = spErr.ChallengeURL + } + if spErr.CLIHint != "" { + errData["hint"] = spErr.CLIHint + } + + env := map[string]interface{}{"ok": false, "error": errData} + b, err := json.MarshalIndent(env, "", " ") + if err != nil { + fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`) + return + } + fmt.Fprintln(w, string(b)) +} + +// installTipsHelpFunc wraps the default help function to append a TIPS section +// when a command has tips set via cmdutil.SetTips. +func installTipsHelpFunc(root *cobra.Command) { + defaultHelp := root.HelpFunc() + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { + defaultHelp(cmd, args) + tips := cmdutil.GetTips(cmd) + if len(tips) == 0 { + return + } + out := cmd.OutOrStdout() + fmt.Fprintln(out) + fmt.Fprintln(out, "Tips:") + for _, tip := range tips { + fmt.Fprintf(out, " • %s\n", tip) + } + }) +} + +// enrichPermissionError adds console_url and improves the hint for permission errors. +// It differentiates between: +// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the API scope → hint to admin console +// - LarkErrUserScopeInsufficient (99991679): user has not authorized the scope → hint to auth login --scope +func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) { + if exitErr.Detail == nil || exitErr.Detail.Type != "permission" { + return + } + // Extract required scopes from API error detail + scopes := extractRequiredScopes(exitErr.Detail.Detail) + if len(scopes) == 0 { + return + } + + cfg, err := f.Config() + if err != nil { + return + } + + // Select the recommended (least-privilege) scope + scopeIfaces := make([]interface{}, len(scopes)) + for i, s := range scopes { + scopeIfaces[i] = s + } + recommended := registry.SelectRecommendedScope(scopeIfaces, "tenant") + if recommended == "" { + recommended = scopes[0] + } + + // Build admin console URL with the recommended scope + host := "open.feishu.cn" + if cfg.Brand == "lark" { + host = "open.larksuite.com" + } + consoleURL := fmt.Sprintf("https://%s/page/scope-apply?clientID=%s&scopes=%s", host, url.QueryEscape(cfg.AppID), url.QueryEscape(recommended)) + + // Clear raw API detail — useful info is now in message/hint/console_url + exitErr.Detail.Detail = nil + + isBot := f.ResolvedIdentity.IsBot() + + larkCode := exitErr.Detail.Code + switch larkCode { + case output.LarkErrUserScopeInsufficient, output.LarkErrUserNotAuthorized: + // User has not authorized the scope → re-authorize + exitErr.Detail.Message = fmt.Sprintf("User not authorized: required scope %s [%d]", recommended, larkCode) + if isBot { + exitErr.Detail.Hint = "enable the scope in developer console (see console_url)" + } else { + exitErr.Detail.Hint = fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended) + } + exitErr.Detail.ConsoleURL = consoleURL + + case output.LarkErrAppScopeNotEnabled: + // App has not enabled the API scope → admin console + exitErr.Detail.Message = fmt.Sprintf("App scope not enabled: required scope %s [%d]", recommended, larkCode) + exitErr.Detail.Hint = "enable the scope in developer console (see console_url)" + exitErr.Detail.ConsoleURL = consoleURL + + default: + // Other permission errors (matched by keyword) + exitErr.Detail.Message = fmt.Sprintf("Permission denied: required scope %s [%d]", recommended, larkCode) + if isBot { + exitErr.Detail.Hint = "enable the scope in developer console (see console_url)" + } else { + exitErr.Detail.Hint = fmt.Sprintf( + "enable scope in console (see console_url), or run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended) + } + exitErr.Detail.ConsoleURL = consoleURL + } +} + +// extractRequiredScopes extracts scope names from the API error's permission_violations field. +func extractRequiredScopes(detail interface{}) []string { + m, ok := detail.(map[string]interface{}) + if !ok { + return nil + } + violations, ok := m["permission_violations"].([]interface{}) + if !ok { + return nil + } + var scopes []string + for _, v := range violations { + vm, ok := v.(map[string]interface{}) + if !ok { + continue + } + if subject, ok := vm["subject"].(string); ok { + scopes = append(scopes, subject) + } + } + return scopes +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 00000000..f5668d5e --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,189 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/cmd/api" + "github.com/larksuite/cli/cmd/auth" + cmdconfig "github.com/larksuite/cli/cmd/config" + "github.com/larksuite/cli/cmd/schema" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that +// auth, config, and schema commands have auth check disabled, +// while api does not. +func TestPersistentPreRunE_AuthCheckDisabledAnnotations(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + authCmd := auth.NewCmdAuth(f) + if !cmdutil.IsAuthCheckDisabled(authCmd) { + t.Error("expected auth command to have auth check disabled") + } + + configCmd := cmdconfig.NewCmdConfig(f) + if !cmdutil.IsAuthCheckDisabled(configCmd) { + t.Error("expected config command to have auth check disabled") + } + + schemaCmd := schema.NewCmdSchema(f, nil) + if !cmdutil.IsAuthCheckDisabled(schemaCmd) { + t.Error("expected schema command to have auth check disabled") + } + + apiCmd := api.NewCmdApi(f, nil) + if cmdutil.IsAuthCheckDisabled(apiCmd) { + t.Error("expected api command to NOT have auth check disabled") + } +} + +func TestPersistentPreRunE_AuthSubcommands(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + authCmd := auth.NewCmdAuth(f) + for _, sub := range authCmd.Commands() { + if !cmdutil.IsAuthCheckDisabled(sub) { + t.Errorf("expected auth subcommand %q to inherit disabled auth check", sub.Name()) + } + } +} + +func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + configCmd := cmdconfig.NewCmdConfig(f) + for _, sub := range configCmd.Commands() { + if !cmdutil.IsAuthCheckDisabled(sub) { + t.Errorf("expected config subcommand %q to inherit disabled auth check", sub.Name()) + } + } +} + +func TestHandleRootError_RawError_SkipsEnrichmentAndEnvelope(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + // Create a permission error (would normally be enriched) and mark it Raw + err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "calendar:calendar:readonly"}, + }, + }) + err.Raw = true + + code := handleRootError(f, err) + if code != output.ExitAPI { + t.Errorf("expected exit code %d, got %d", output.ExitAPI, code) + } + // stderr should be empty — no envelope written + if stderr.Len() != 0 { + t.Errorf("expected empty stderr for Raw error, got: %s", stderr.String()) + } + // The message should NOT have been enriched by enrichPermissionError + // (ErrAPI sets "Permission denied [code]" but enrichment would replace it with "App scope not enabled: ...") + if strings.Contains(err.Error(), "App scope not enabled") { + t.Errorf("expected message not enriched, got: %s", err.Error()) + } + // Detail.Detail should be preserved (enrichPermissionError clears it to nil) + if err.Detail != nil && err.Detail.Detail == nil { + t.Error("expected Detail.Detail to be preserved, but it was cleared") + } +} + +func TestHandleRootError_NonRawError_EnrichesAndWritesEnvelope(t *testing.T) { + f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + // Create a permission error without Raw — should be enriched + err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": "calendar:calendar:readonly"}, + }, + }) + + code := handleRootError(f, err) + if code != output.ExitAPI { + t.Errorf("expected exit code %d, got %d", output.ExitAPI, code) + } + // stderr should contain the error envelope + if stderr.Len() == 0 { + t.Error("expected non-empty stderr for non-Raw error") + } + // The message should have been enriched + if !strings.Contains(err.Error(), "App scope not enabled") { + t.Errorf("expected enriched message, got: %s", err.Error()) + } +} + +func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) { + tests := []struct { + name string + appID string + scope string + wantInURL string // substring that must appear in console_url + denyInURL string // substring that must NOT appear raw in console_url + }{ + { + name: "ampersand in scope", + appID: "cli_good", + scope: "scope&evil=injected", + wantInURL: "scopes=scope%26evil%3Dinjected", + denyInURL: "scopes=scope&evil=injected", + }, + { + name: "hash in scope", + appID: "cli_good", + scope: "scope#fragment", + wantInURL: "scopes=scope%23fragment", + denyInURL: "scopes=scope#fragment", + }, + { + name: "space in scope", + appID: "cli_good", + scope: "scope with spaces", + wantInURL: "scopes=scope+with+spaces", + }, + { + name: "special chars in appID", + appID: "app&id=bad", + scope: "calendar:calendar:readonly", + wantInURL: "clientID=app%26id%3Dbad", + denyInURL: "clientID=app&id=bad", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: tt.appID, AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + exitErr := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "scope not enabled", map[string]interface{}{ + "permission_violations": []interface{}{ + map[string]interface{}{"subject": tt.scope}, + }, + }) + + handleRootError(f, exitErr) + + consoleURL := exitErr.Detail.ConsoleURL + if consoleURL == "" { + t.Fatal("expected console_url to be set") + } + if !strings.Contains(consoleURL, tt.wantInURL) { + t.Errorf("console_url missing expected escaped value\n want substring: %s\n got url: %s", tt.wantInURL, consoleURL) + } + if tt.denyInURL != "" && strings.Contains(consoleURL, tt.denyInURL) { + t.Errorf("console_url contains unescaped dangerous value\n deny substring: %s\n got url: %s", tt.denyInURL, consoleURL) + } + }) + } +} diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go new file mode 100644 index 00000000..81daee13 --- /dev/null +++ b/cmd/schema/schema.go @@ -0,0 +1,500 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/internal/util" + "github.com/spf13/cobra" +) + +// SchemaOptions holds all inputs for the schema command. +type SchemaOptions struct { + Factory *cmdutil.Factory + + // Positional args + Path string + + // Flags + Format string +} + +func printServices(w io.Writer) { + services := registry.ListFromMetaProjects() + fmt.Fprintf(w, "%sAvailable services:%s\n\n", output.Bold, output.Reset) + for _, s := range services { + spec := registry.LoadFromMeta(s) + title := registry.GetStrFromMap(spec, "title") + if title == "" { + title = registry.GetStrFromMap(spec, "description") + } + fmt.Fprintf(w, " %s%s%s %s%s%s\n", output.Cyan, s, output.Reset, output.Dim, title, output.Reset) + } + fmt.Fprintf(w, "\n%sUsage: lark-cli schema ..%s\n", output.Dim, output.Reset) +} + +func printResourceList(w io.Writer, spec map[string]interface{}) { + name := registry.GetStrFromMap(spec, "name") + version := registry.GetStrFromMap(spec, "version") + title := registry.GetStrFromMap(spec, "title") + if title == "" { + title = registry.GetStrFromMap(spec, "description") + } + servicePath := registry.GetStrFromMap(spec, "servicePath") + + fmt.Fprintf(w, "%s%s%s (%s) — %s\n\n", output.Bold, name, output.Reset, version, title) + fmt.Fprintf(w, "%sBase path: %s%s\n\n", output.Dim, servicePath, output.Reset) + + resources, _ := spec["resources"].(map[string]interface{}) + for _, resName := range sortedKeys(resources) { + fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset) + resMap, _ := resources[resName].(map[string]interface{}) + methods, _ := resMap["methods"].(map[string]interface{}) + for _, methodName := range sortedKeys(methods) { + m, _ := methods[methodName].(map[string]interface{}) + httpMethod := registry.GetStrFromMap(m, "httpMethod") + desc := registry.GetStrFromMap(m, "description") + danger := "" + if d, _ := m["danger"].(bool); d { + danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset) + } + fmt.Fprintf(w, " %-7s %s%s%s %s%s%s%s\n", httpMethod, output.Bold, methodName, output.Reset, output.Dim, desc, output.Reset, danger) + } + fmt.Fprintln(w) + } + fmt.Fprintf(w, "%sUsage: lark-cli schema %s..%s\n", output.Dim, name, output.Reset) +} + +func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) { + servicePath := registry.GetStrFromMap(spec, "servicePath") + specName := registry.GetStrFromMap(spec, "name") + methodPath := registry.GetStrFromMap(method, "path") + fullPath := servicePath + "/" + methodPath + httpMethod := registry.GetStrFromMap(method, "httpMethod") + desc := registry.GetStrFromMap(method, "description") + + fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset) + + httpColor := output.Yellow + if httpMethod == "GET" { + httpColor = output.Green + } else if httpMethod == "DELETE" { + httpColor = output.Red + } + fmt.Fprintf(w, " %s%s%s %s\n", httpColor, httpMethod, output.Reset, fullPath) + if desc != "" { + fmt.Fprintf(w, " %s\n", desc) + } + fmt.Fprintln(w) + + // Parameters + params, _ := method["parameters"].(map[string]interface{}) + if len(params) > 0 { + fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset) + fmt.Fprintf(w, " %s--params%s %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset) + for _, paramName := range sortedParamKeys(params) { + p, _ := params[paramName].(map[string]interface{}) + pType := registry.GetStrFromMap(p, "type") + if pType == "" { + pType = "string" + } + location := registry.GetStrFromMap(p, "location") + required, _ := p["required"].(bool) + reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset) + if required { + reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset) + } + locColor := output.Dim + if location == "path" { + locColor = output.Yellow + } + // Options (enum values) + optStr := formatOptions(p) + fmt.Fprintf(w, " - %s%s%s (%s, %s%s%s, %s)%s\n", output.Cyan, paramName, output.Reset, pType, locColor, location, output.Reset, reqStr, optStr) + if pdesc := registry.GetStrFromMap(p, "description"); pdesc != "" { + pdesc = util.TruncateStrWithEllipsis(pdesc, 100) + fmt.Fprintf(w, " %s%s%s\n", output.Dim, pdesc, output.Reset) + } + if ex := registry.GetStrFromMap(p, "example"); ex != "" { + fmt.Fprintf(w, " %se.g. %s%s\n", output.Dim, ex, output.Reset) + } + if rangeStr := formatRange(p); rangeStr != "" { + fmt.Fprintf(w, " %srange: %s%s\n", output.Dim, rangeStr, output.Reset) + } + } + fmt.Fprintln(w) + } + + // --data for write methods + if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" || httpMethod == "DELETE" { + if len(params) == 0 { + fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset) + } + fmt.Fprintf(w, " %s--data%s %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset) + requestBody, _ := method["requestBody"].(map[string]interface{}) + if len(requestBody) > 0 { + printNestedFields(w, requestBody, " ", "") + } + fmt.Fprintln(w) + } + + // Response + responseBody, _ := method["responseBody"].(map[string]interface{}) + if len(responseBody) > 0 { + fmt.Fprintf(w, "%sResponse:%s\n\n", output.Bold, output.Reset) + printNestedFields(w, responseBody, " ", "") + fmt.Fprintln(w) + } + + // Identity + if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 { + var identities []string + for _, t := range tokens { + if s, ok := t.(string); ok { + switch s { + case "user": + identities = append(identities, "user") + case "tenant": + identities = append(identities, "bot") + } + } + } + if len(identities) > 0 { + fmt.Fprintf(w, "%sIdentity:%s %s\n", output.Bold, output.Reset, strings.Join(identities, ", ")) + } + } + + // Scopes (all) + if scopes, ok := method["scopes"].([]interface{}); ok && len(scopes) > 0 { + var scopeStrs []string + for _, s := range scopes { + if str, ok := s.(string); ok { + scopeStrs = append(scopeStrs, str) + } + } + fmt.Fprintf(w, "%sScopes:%s %s\n", output.Bold, output.Reset, strings.Join(scopeStrs, ", ")) + } + + // CLI example + fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName) + + // Docs + if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" { + fmt.Fprintf(w, "%sDocs:%s %s\n", output.Bold, output.Reset, docUrl) + } +} + +func printNestedFields(w io.Writer, fields map[string]interface{}, indent, prefix string) { + for _, fieldName := range sortedFieldKeys(fields) { + f, _ := fields[fieldName].(map[string]interface{}) + fullName := fieldName + if prefix != "" { + fullName = prefix + "." + fieldName + } + fType := registry.GetStrFromMap(f, "type") + required, _ := f["required"].(bool) + reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset) + if required { + reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset) + } + optStr := formatOptions(f) + fmt.Fprintf(w, "%s- %s%s%s (%s, %s)%s\n", indent, output.Cyan, fullName, output.Reset, fType, reqStr, optStr) + desc := registry.GetStrFromMap(f, "description") + if desc != "" { + desc = util.TruncateStrWithEllipsis(desc, 100) + fmt.Fprintf(w, "%s %s%s%s\n", indent, output.Dim, desc, output.Reset) + } + if ex := registry.GetStrFromMap(f, "example"); ex != "" { + fmt.Fprintf(w, "%s %se.g. %s%s\n", indent, output.Dim, ex, output.Reset) + } + if rangeStr := formatRange(f); rangeStr != "" { + fmt.Fprintf(w, "%s %srange: %s%s\n", indent, output.Dim, rangeStr, output.Reset) + } + if props, ok := f["properties"].(map[string]interface{}); ok && len(props) > 0 { + printNestedFields(w, props, indent+" ", fullName) + } + } +} + +// formatOptions returns " — val1 | val2 | ..." if field has options, else "". +func formatOptions(f map[string]interface{}) string { + opts, ok := f["options"].([]interface{}) + if !ok || len(opts) == 0 { + return "" + } + var vals []string + for _, o := range opts { + if om, ok := o.(map[string]interface{}); ok { + if v := registry.GetStrFromMap(om, "value"); v != "" { + vals = append(vals, v) + } + } + } + if len(vals) == 0 { + return "" + } + return fmt.Sprintf(" %s— %s%s", output.Dim, strings.Join(vals, " | "), output.Reset) +} + +// formatRange returns "min..max" if field has min/max, else "". +func formatRange(f map[string]interface{}) string { + minVal := registry.GetStrFromMap(f, "min") + maxVal := registry.GetStrFromMap(f, "max") + if minVal == "" && maxVal == "" { + return "" + } + if minVal != "" && maxVal != "" { + return minVal + ".." + maxVal + } + if minVal != "" { + return ">=" + minVal + } + return "<=" + maxVal +} + +// sortedKeys returns map keys in alphabetical order. +func sortedKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// sortedParamKeys returns parameter keys sorted: required first, then alphabetical. +func sortedParamKeys(params map[string]interface{}) []string { + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + pi, _ := params[keys[i]].(map[string]interface{}) + pj, _ := params[keys[j]].(map[string]interface{}) + ri, _ := pi["required"].(bool) + rj, _ := pj["required"].(bool) + if ri != rj { + return ri + } + return keys[i] < keys[j] + }) + return keys +} + +// sortedFieldKeys returns field keys sorted: required first, then alphabetical. +func sortedFieldKeys(fields map[string]interface{}) []string { + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + fi, _ := fields[keys[i]].(map[string]interface{}) + fj, _ := fields[keys[j]].(map[string]interface{}) + ri, _ := fi["required"].(bool) + rj, _ := fj["required"].(bool) + if ri != rj { + return ri + } + return keys[i] < keys[j] + }) + return keys +} + +func findResourceByPath(resources map[string]interface{}, parts []string) (map[string]interface{}, string, []string) { + for i := len(parts); i >= 1; i-- { + candidateName := strings.Join(parts[:i], ".") + if res, ok := resources[candidateName]; ok { + if resMap, ok := res.(map[string]interface{}); ok { + return resMap, candidateName, parts[i:] + } + } + } + return nil, "", nil +} + +// NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook). +func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Command { + opts := &SchemaOptions{Factory: f} + + cmd := &cobra.Command{ + Use: "schema [path]", + Short: "View API method parameters, types, and scopes", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + opts.Path = args[0] + } + if runF != nil { + return runF(opts) + } + return schemaRun(opts) + }, + } + cmdutil.DisableAuthCheck(cmd) + + cmd.ValidArgsFunction = completeSchemaPath + cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") + _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp + }) + + return cmd +} + +// completeSchemaPath provides tab-completion for the schema path argument. +// It handles dotted resource names (e.g. app.table.fields) by iterating all +// resources and classifying each as a prefix-match or fully-matched. +func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + parts := strings.Split(toComplete, ".") + + // Level 1: complete service names + if len(parts) <= 1 { + var completions []string + for _, s := range registry.ListFromMetaProjects() { + if strings.HasPrefix(s, toComplete) { + completions = append(completions, s+".") + } + } + return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace + } + + serviceName := parts[0] + spec := registry.LoadFromMeta(serviceName) + if spec == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + resources, _ := spec["resources"].(map[string]interface{}) + if resources == nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // afterService = everything user typed after "serviceName." + afterService := strings.Join(parts[1:], ".") + + var completions []string + + for resName, resVal := range resources { + if strings.HasPrefix(resName, afterService) { + // afterService is a prefix of this resource name → resource candidate + completions = append(completions, serviceName+"."+resName+".") + } else if strings.HasPrefix(afterService, resName+".") { + // This resource is fully matched; remainder is method prefix + methodPrefix := afterService[len(resName)+1:] + resMap, _ := resVal.(map[string]interface{}) + if resMap == nil { + continue + } + methods, _ := resMap["methods"].(map[string]interface{}) + for methodName := range methods { + if strings.HasPrefix(methodName, methodPrefix) { + completions = append(completions, serviceName+"."+resName+"."+methodName) + } + } + } + } + + sort.Strings(completions) + + // If all completions end with ".", user is still navigating resources → NoSpace + allTrailingDot := len(completions) > 0 + for _, c := range completions { + if !strings.HasSuffix(c, ".") { + allTrailingDot = false + break + } + } + directive := cobra.ShellCompDirectiveNoFileComp + if allTrailingDot { + directive |= cobra.ShellCompDirectiveNoSpace + } + return completions, directive +} + +func schemaRun(opts *SchemaOptions) error { + out := opts.Factory.IOStreams.Out + + if opts.Path == "" { + printServices(out) + return nil + } + + parts := strings.Split(opts.Path, ".") + + serviceName := parts[0] + spec := registry.LoadFromMeta(serviceName) + if spec == nil { + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown service: %s", serviceName), + fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", "))) + } + + if len(parts) == 1 { + if opts.Format == "pretty" { + printResourceList(out, spec) + } else { + output.PrintJson(out, spec) + } + return nil + } + + resources, _ := spec["resources"].(map[string]interface{}) + resource, resName, remaining := findResourceByPath(resources, parts[1:]) + if resource == nil { + var resNames []string + for k := range resources { + resNames = append(resNames, k) + } + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")), + fmt.Sprintf("Available: %s", strings.Join(resNames, ", "))) + } + + if len(remaining) == 0 { + if opts.Format == "pretty" { + fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset) + methods, _ := resource["methods"].(map[string]interface{}) + for _, mName := range sortedKeys(methods) { + m, _ := methods[mName].(map[string]interface{}) + httpMethod := registry.GetStrFromMap(m, "httpMethod") + desc := registry.GetStrFromMap(m, "description") + fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset) + } + fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.%s\n", output.Dim, serviceName, resName, output.Reset) + } else { + output.PrintJson(out, resource) + } + return nil + } + + methodName := remaining[0] + methods, _ := resource["methods"].(map[string]interface{}) + method, ok := methods[methodName].(map[string]interface{}) + if !ok { + var mNames []string + for k := range methods { + mNames = append(mNames, k) + } + return output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName), + fmt.Sprintf("Available: %s", strings.Join(mNames, ", "))) + } + + if opts.Format == "pretty" { + printMethodDetail(out, spec, resName, methodName, method) + } else { + output.PrintJson(out, method) + } + return nil +} diff --git a/cmd/schema/schema_test.go b/cmd/schema/schema_test.go new file mode 100644 index 00000000..2f36159a --- /dev/null +++ b/cmd/schema/schema_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package schema + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" +) + +func TestSchemaCmd_FlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *SchemaOptions + cmd := NewCmdSchema(f, func(opts *SchemaOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"calendar.events.list", "--format", "pretty"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.Path != "calendar.events.list" { + t.Errorf("expected path calendar.events.list, got %s", gotOpts.Path) + } + if gotOpts.Format != "pretty" { + t.Errorf("expected Format=pretty, got %s", gotOpts.Format) + } +} + +func TestSchemaCmd_NoArgs(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "Available services") { + t.Error("expected service list output") + } +} + +func TestSchemaCmd_UnknownService(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdSchema(f, nil) + cmd.SetArgs([]string{"nonexistent_service"}) + err := cmd.Execute() + if err == nil { + t.Error("expected error for unknown service") + } + if !strings.Contains(err.Error(), "Unknown service") { + t.Errorf("expected 'Unknown service' error, got: %v", err) + } +} diff --git a/cmd/service/service.go b/cmd/service/service.go new file mode 100644 index 00000000..e3fceded --- /dev/null +++ b/cmd/service/service.go @@ -0,0 +1,432 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/spf13/cobra" +) + +// RegisterServiceCommands registers all service commands from from_meta specs. +func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) { + for _, project := range registry.ListFromMetaProjects() { + spec := registry.LoadFromMeta(project) + if spec == nil { + continue + } + specName := registry.GetStrFromMap(spec, "name") + servicePath := registry.GetStrFromMap(spec, "servicePath") + if specName == "" || servicePath == "" { + continue + } + resources, _ := spec["resources"].(map[string]interface{}) + if resources == nil { + continue + } + registerService(parent, spec, resources, f) + } +} + +func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) { + specName := registry.GetStrFromMap(spec, "name") + specDesc := registry.GetServiceDescription(specName, "en") + if specDesc == "" { + specDesc = registry.GetStrFromMap(spec, "description") + } + + // Find existing service command or create one + var svc *cobra.Command + for _, c := range parent.Commands() { + if c.Name() == specName { + svc = c + break + } + } + if svc == nil { + svc = &cobra.Command{ + Use: specName, + Short: specDesc, + } + parent.AddCommand(svc) + } + + for resName, resource := range resources { + resMap, _ := resource.(map[string]interface{}) + if resMap == nil { + continue + } + registerResource(svc, spec, resName, resMap, f) + } +} + +func registerResource(parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) { + res := &cobra.Command{ + Use: name, + Short: name + " operations", + } + parent.AddCommand(res) + + methods, _ := resource["methods"].(map[string]interface{}) + for methodName, method := range methods { + methodMap, _ := method.(map[string]interface{}) + if methodMap == nil { + continue + } + registerMethod(res, spec, methodMap, methodName, name, f) + } +} + +// ServiceMethodOptions holds all inputs for a dynamically registered service method command. +type ServiceMethodOptions struct { + Factory *cmdutil.Factory + Cmd *cobra.Command + Ctx context.Context + Spec map[string]interface{} + Method map[string]interface{} + SchemaPath string + + // Flags + Params string + Data string + As core.Identity + Output string + PageAll bool + PageLimit int + PageDelay int + Format string + DryRun bool +} + +func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) { + parent.AddCommand(NewCmdServiceMethod(f, spec, method, name, resName, nil)) +} + +// NewCmdServiceMethod creates a command for a dynamically registered service method. +func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command { + desc := registry.GetStrFromMap(method, "description") + httpMethod := registry.GetStrFromMap(method, "httpMethod") + specName := registry.GetStrFromMap(spec, "name") + schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name) + + opts := &ServiceMethodOptions{ + Factory: f, + Spec: spec, + Method: method, + SchemaPath: schemaPath, + } + var asStr string + + cmd := &cobra.Command{ + Use: name, + Short: desc, + Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Cmd = cmd + opts.Ctx = cmd.Context() + opts.As = core.Identity(asStr) + if runF != nil { + return runF(opts) + } + return serviceMethodRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON") + switch httpMethod { + case "POST", "PUT", "PATCH", "DELETE": + cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON") + } + cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)") + cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses") + cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages") + cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") + cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") + cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") + + _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp + }) + _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp + }) + + cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips")) + + return cmd +} + +func serviceMethodRun(opts *ServiceMethodOptions) error { + f := opts.Factory + opts.As = f.ResolveAs(opts.Cmd, opts.As) + + // Check if this API method supports the resolved identity. + if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 { + if err := f.CheckIdentity(opts.As, cmdutil.AccessTokensToIdentities(tokens)); err != nil { + return err + } + } + + if opts.PageAll && opts.Output != "" { + return output.ErrValidation("--output and --page-all are mutually exclusive") + } + + config, err := f.ResolveConfig(opts.As) + if err != nil { + return err + } + // Identity info is now included in the JSON envelope; skip stderr printing. + // cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected) + + scopes, _ := opts.Method["scopes"].([]interface{}) + if !opts.As.IsBot() { + if err := checkServiceScopes(config, opts.Method, scopes); err != nil { + return err + } + } + + request, err := buildServiceRequest(opts) + if err != nil { + return err + } + + if opts.DryRun { + return serviceDryRun(f, request, config, opts.Format) + } + + ac, err := f.NewAPIClientWithConfig(config) + if err != nil { + return err + } + + out := f.IOStreams.Out + format, formatOK := output.ParseFormat(opts.Format) + if !formatOK { + fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format) + } + + checkErr := scopeAwareChecker(scopes, opts.As.IsBot()) + + if opts.PageAll { + return servicePaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut, + client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr) + } + + resp, err := ac.DoAPI(opts.Ctx, request) + if err != nil { + return output.ErrNetwork("API call failed: %s", err) + } + return client.HandleResponse(resp, client.ResponseOptions{ + OutputPath: opts.Output, + Format: format, + Out: out, + ErrOut: f.IOStreams.ErrOut, + CheckError: checkErr, + }) +} + +// checkServiceScopes pre-checks user scopes before making the API call. +func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error { + requiredScopes, hasRequired := method["requiredScopes"].([]interface{}) + + if hasRequired && len(requiredScopes) > 0 { + // Strict: ALL requiredScopes must be present + stored := auth.GetStoredToken(config.AppID, config.UserOpenId) + if stored != nil { + required := make([]string, 0, len(requiredScopes)) + for _, s := range requiredScopes { + if str, ok := s.(string); ok { + required = append(required, str) + } + } + if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 { + return output.ErrWithHint(output.ExitAuth, "missing_scope", + fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), + fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) + } + } + return nil + } + + if len(scopes) == 0 { + return nil + } + + // Default: ANY one of the declared scopes is sufficient + stored := auth.GetStoredToken(config.AppID, config.UserOpenId) + if stored == nil { + return nil + } + grantedScopes := make(map[string]bool) + for _, s := range strings.Fields(stored.Scope) { + grantedScopes[s] = true + } + for _, s := range scopes { + if str, ok := s.(string); ok && grantedScopes[str] { + return nil + } + } + recommended := registry.SelectRecommendedScope(scopes, "user") + return output.ErrWithHint(output.ExitAPI, "permission", + fmt.Sprintf("insufficient permissions (required scope: %s)", recommended), + fmt.Sprintf(`run `+"`"+`lark-cli auth login --scope "%s"`+"`"+` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.`, recommended)) +} + +// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest. +func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, error) { + spec := opts.Spec + method := opts.Method + schemaPath := opts.SchemaPath + httpMethod := registry.GetStrFromMap(method, "httpMethod") + + var params map[string]interface{} + if opts.Params != "" { + if err := json.Unmarshal([]byte(opts.Params), ¶ms); err != nil { + return client.RawApiRequest{}, output.ErrValidation("--params invalid JSON format") + } + } else { + params = map[string]interface{}{} + } + + url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path") + + parameters, _ := method["parameters"].(map[string]interface{}) + for name, param := range parameters { + p, _ := param.(map[string]interface{}) + if registry.GetStrFromMap(p, "location") != "path" { + continue + } + val, ok := params[name] + if !ok || util.IsEmptyValue(val) { + return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("missing required path parameter: %s", name), + fmt.Sprintf("lark-cli schema %s", schemaPath)) + } + valStr := fmt.Sprintf("%v", val) + if err := validate.ResourceName(valStr, name); err != nil { + return client.RawApiRequest{}, output.ErrValidation("%s", err) + } + url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1) + delete(params, name) + } + + queryParams := map[string]interface{}{} + for name, param := range parameters { + p, _ := param.(map[string]interface{}) + if registry.GetStrFromMap(p, "location") != "query" { + continue + } + value, exists := params[name] + required, _ := p["required"].(bool) + isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size") + if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) { + return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation", + fmt.Sprintf("missing required query parameter: %s", name), + fmt.Sprintf("lark-cli schema %s", schemaPath)) + } + if exists && !util.IsEmptyValue(value) { + queryParams[name] = value + } + } + for name, value := range params { + if _, ok := queryParams[name]; !ok { + queryParams[name] = value + } + } + + data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data) + if err != nil { + return client.RawApiRequest{}, err + } + + request := client.RawApiRequest{ + Method: httpMethod, + URL: url, + Params: queryParams, + Data: data, + As: opts.As, + } + if opts.Output != "" { + request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload()) + } + return request, nil +} + +func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error { + return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format) +} + +// scopeAwareChecker returns an error checker that enriches scope-related errors with login hints. +func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) error { + return func(result interface{}) error { + resultMap, ok := result.(map[string]interface{}) + if !ok || resultMap == nil { + return nil + } + code, _ := util.ToFloat64(resultMap["code"]) + if code == 0 { + return nil + } + larkCode := int(code) + msg := registry.GetStrFromMap(resultMap, "msg") + + if larkCode == output.LarkErrUserScopeInsufficient && len(scopes) > 0 { + identity := "user" + if isBotMode { + identity = "tenant" + } + recommended := registry.SelectRecommendedScope(scopes, identity) + return output.ErrWithHint(output.ExitAPI, "permission", + fmt.Sprintf("insufficient permissions: [%d] %s", larkCode, msg), + fmt.Sprintf(`run `+"`"+`lark-cli auth login --scope "%s"`+"`"+` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.`, recommended)) + } + + return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"]) + } +} + +func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error { + switch format { + case output.FormatNDJSON, output.FormatTable, output.FormatCSV: + pf := output.NewPaginatedFormatter(out, format) + result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) { + pf.FormatPage(items) + }, pagOpts) + if err != nil { + return output.ErrNetwork("API call failed: %s", err) + } + if apiErr := checkErr(result); apiErr != nil { + return apiErr + } + if !hasItems { + fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format) + output.FormatValue(out, result, output.FormatJSON) + } + return nil + default: + result, err := ac.PaginateAll(ctx, request, pagOpts) + if err != nil { + return output.ErrNetwork("API call failed: %s", err) + } + if apiErr := checkErr(result); apiErr != nil { + return apiErr + } + output.FormatValue(out, result, format) + return nil + } +} diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go new file mode 100644 index 00000000..55c0986d --- /dev/null +++ b/cmd/service/service_test.go @@ -0,0 +1,552 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// ── helpers ── + +var testConfig = &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, +} + +func driveSpec() map[string]interface{} { + return map[string]interface{}{ + "name": "drive", + "servicePath": "/open-apis/drive/v1", + } +} + +func driveMethod(httpMethod string, params map[string]interface{}) map[string]interface{} { + m := map[string]interface{}{ + "path": "files/{file_token}/copy", + "httpMethod": httpMethod, + } + if params != nil { + m["parameters"] = params + } else { + m["parameters"] = map[string]interface{}{ + "file_token": map[string]interface{}{ + "type": "string", "location": "path", "required": true, + }, + } + } + return m +} + +func tokenStub() *httpmock.Stub { + return &httpmock.Stub{ + URL: "tenant_access_token", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test", "expire": 7200, + }, + } +} + +// ── registerService ── + +func TestRegisterService(t *testing.T) { + parent := &cobra.Command{Use: "root"} + f := &cmdutil.Factory{} + spec := map[string]interface{}{ + "name": "base", + "description": "Base API", + "servicePath": "/open-apis/base/v3", + } + resources := map[string]interface{}{ + "tables": map[string]interface{}{ + "methods": map[string]interface{}{ + "list": map[string]interface{}{ + "description": "List tables", + "httpMethod": "GET", + }, + }, + }, + } + + registerService(parent, spec, resources, f) + + // service command exists + svc, _, err := parent.Find([]string{"base"}) + if err != nil || svc.Name() != "base" { + t.Fatalf("expected 'base' command, got err=%v", err) + } + // resource sub-command + res, _, err := parent.Find([]string{"base", "tables"}) + if err != nil || res.Name() != "tables" { + t.Fatalf("expected 'tables' command, got err=%v", err) + } + // method sub-command + meth, _, err := parent.Find([]string{"base", "tables", "list"}) + if err != nil || meth.Name() != "list" { + t.Fatalf("expected 'list' command, got err=%v", err) + } +} + +func TestRegisterService_MergesExistingCommand(t *testing.T) { + parent := &cobra.Command{Use: "root"} + existing := &cobra.Command{Use: "base", Short: "existing"} + parent.AddCommand(existing) + + f := &cmdutil.Factory{} + spec := map[string]interface{}{ + "name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3", + } + resources := map[string]interface{}{ + "tables": map[string]interface{}{ + "methods": map[string]interface{}{ + "list": map[string]interface{}{"description": "List", "httpMethod": "GET"}, + }, + }, + } + + registerService(parent, spec, resources, f) + + // Should reuse existing, not duplicate + count := 0 + for _, c := range parent.Commands() { + if c.Name() == "base" { + count++ + } + } + if count != 1 { + t.Errorf("expected 1 'base' command, got %d", count) + } + // Resource should be added under the existing command + _, _, err := parent.Find([]string{"base", "tables", "list"}) + if err != nil { + t.Fatalf("expected 'list' under existing 'base' command, got err=%v", err) + } +} + +// ── NewCmdServiceMethod flags ── + +func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) { + f := &cmdutil.Factory{} + cmd := NewCmdServiceMethod(f, driveSpec(), + map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", nil) + + if cmd.Flags().Lookup("data") != nil { + t.Error("GET method should not have --data flag") + } + if cmd.Use != "list" { + t.Errorf("expected Use=list, got %s", cmd.Use) + } + if !strings.Contains(cmd.Long, "schema drive.files.list") { + t.Errorf("expected schema path in Long, got %s", cmd.Long) + } +} + +func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) { + f := &cmdutil.Factory{} + cmd := NewCmdServiceMethod(f, driveSpec(), + map[string]interface{}{"description": "desc", "httpMethod": "POST"}, "create", "files", nil) + + if cmd.Flags().Lookup("data") == nil { + t.Error("POST method should have --data flag") + } +} + +func TestNewCmdServiceMethod_RunFCallback(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + + var captured *ServiceMethodOptions + cmd := NewCmdServiceMethod(f, driveSpec(), + map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", + func(opts *ServiceMethodOptions) error { + captured = opts + return nil + }) + cmd.SetArgs([]string{"--as", "bot"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if captured == nil { + t.Fatal("runF was not called") + } + if captured.As != core.AsBot { + t.Errorf("expected As=bot, got %s", captured.As) + } + if captured.SchemaPath != "drive.files.list" { + t.Errorf("expected SchemaPath=drive.files.list, got %s", captured.SchemaPath) + } +} + +// ── dry-run / buildServiceRequest ── + +func TestServiceMethod_DryRun_PathParam(t *testing.T) { + tests := []struct { + name string + fileToken string + wantInURL string + }{ + {"normal token", "boxcn123abc", "/open-apis/drive/v1/files/boxcn123abc/copy"}, + {"hyphen and underscore", "ou_abc-123_def", "/open-apis/drive/v1/files/ou_abc-123_def/copy"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil) + cmd.SetArgs([]string{ + "--params", `{"file_token":"` + tt.fileToken + `"}`, + "--data", `{"name":"test.txt"}`, + "--dry-run", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), tt.wantInURL) { + t.Errorf("expected URL containing %q, got:\n%s", tt.wantInURL, stdout.String()) + } + }) + } +} + +func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) { + tests := []struct { + name string + fileToken string + wantErr string + }{ + {"path traversal with slashes", "../../auth/v3/token", "path traversal"}, + {"single dot-dot", "../admin", "path traversal"}, + {"question mark injection", "token?evil=true", "invalid characters"}, + {"hash injection", "token#fragment", "invalid characters"}, + {"percent-encoded bypass", "token%2F..%2Fadmin", "invalid characters"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil) + cmd.SetArgs([]string{ + "--params", `{"file_token":"` + tt.fileToken + `"}`, + "--data", `{"name":"test.txt"}`, + "--dry-run", + }) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for malicious path parameter") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got: %v", tt.wantErr, err) + } + }) + } +} + +func TestServiceMethod_MissingPathParam(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil) + cmd.SetArgs([]string{"--params", `{}`, "--data", `{}`, "--dry-run"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for missing path param") + } + if !strings.Contains(err.Error(), "missing required path parameter") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) { + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{ + "path": "items", "httpMethod": "GET", + "parameters": map[string]interface{}{ + "q": map[string]interface{}{"location": "query", "required": true}, + }, + } + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--params", `{}`, "--dry-run"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for missing required query param") + } + if !strings.Contains(err.Error(), "missing required query parameter: q") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) { + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{ + "path": "items", "httpMethod": "GET", + "parameters": map[string]interface{}{ + "page_size": map[string]interface{}{"location": "query", "required": true}, + }, + } + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--params", `{}`, "--page-all", "--dry-run"}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err) + } + if !strings.Contains(stdout.String(), "Dry Run") { + t.Error("expected dry-run output") + } +} + +func TestServiceMethod_InvalidParamsJSON(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--params", "{bad", "--dry-run"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for invalid JSON") + } + if !strings.Contains(err.Error(), "--params invalid JSON format") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestServiceMethod_InvalidDataJSON(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil) + cmd.SetArgs([]string{"--data", "{bad", "--dry-run"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for invalid --data JSON") + } + if !strings.Contains(err.Error(), "--data invalid JSON format") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--page-all", "--output", "file.bin", "--as", "bot"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --output + --page-all conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("unexpected error: %v", err) + } +} + +// ── bot mode integration with httpmock ── + +func TestServiceMethod_BotMode_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, testConfig) + + reg.Register(tokenStub()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"result": "success"}, + }, + }) + + spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} + method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "success") { + t.Errorf("expected 'success' in output, got:\n%s", stdout.String()) + } +} + +func TestServiceMethod_BotMode_APIError(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu, + }) + + reg.Register(tokenStub()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{"code": 40003, "msg": "invalid token"}, + }) + + spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} + method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected API error") + } + var exitErr *output.ExitError + if !isExitError(err, &exitErr) { + t.Fatalf("expected ExitError, got: %T %v", err, err) + } + if exitErr.Code != output.ExitAPI { + t.Errorf("expected ExitAPI code, got %d", exitErr.Code) + } + // stdout must be empty on API error — error details belong in stderr envelope only. + // This guards against re-introducing duplicate output (see commit 86215a10). + if stdout.Len() > 0 { + t.Errorf("expected no stdout on API error, got: %s", stdout.String()) + } +} + +func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu, + }) + + reg.Register(tokenStub()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "1"}}, + "has_more": false, + }, + }, + }) + + spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} + method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot", "--page-all"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"id"`) { + t.Errorf("expected items in output, got:\n%s", stdout.String()) + } +} + +func TestServiceMethod_UnknownFormat_Warning(t *testing.T) { + f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu, + }) + + reg.Register(tokenStub()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + }) + + spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} + method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stderr.String(), "warning: unknown format") { + t.Errorf("expected format warning in stderr, got:\n%s", stderr.String()) + } +} + +// ── scopeAwareChecker ── + +func TestScopeAwareChecker_Success(t *testing.T) { + checker := scopeAwareChecker(nil, false) + err := checker(map[string]interface{}{"code": 0.0, "msg": "ok"}) + if err != nil { + t.Errorf("expected nil error for code=0, got: %v", err) + } +} + +func TestScopeAwareChecker_NonMapResult(t *testing.T) { + checker := scopeAwareChecker(nil, false) + err := checker("not a map") + if err != nil { + t.Errorf("expected nil for non-map result, got: %v", err) + } +} + +func TestScopeAwareChecker_APIError(t *testing.T) { + checker := scopeAwareChecker(nil, false) + err := checker(map[string]interface{}{"code": 40003.0, "msg": "bad request"}) + if err == nil { + t.Fatal("expected error for non-zero code") + } + if !strings.Contains(err.Error(), "API error: [40003]") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestScopeAwareChecker_ScopeError_UserMode(t *testing.T) { + scopes := []interface{}{"calendar:read"} + checker := scopeAwareChecker(scopes, false) + err := checker(map[string]interface{}{ + "code": float64(output.LarkErrUserScopeInsufficient), + "msg": "scope insufficient", + }) + if err == nil { + t.Fatal("expected permission error") + } + var exitErr *output.ExitError + if !isExitError(err, &exitErr) { + t.Fatalf("expected ExitError, got %T", err) + } + if exitErr.Detail.Type != "permission" { + t.Errorf("expected type=permission, got %s", exitErr.Detail.Type) + } + if !strings.Contains(exitErr.Detail.Hint, "auth login") { + t.Errorf("expected auth login hint, got %s", exitErr.Detail.Hint) + } +} + +func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) { + scopes := []interface{}{"calendar:read"} + checker := scopeAwareChecker(scopes, true) + err := checker(map[string]interface{}{ + "code": float64(output.LarkErrUserScopeInsufficient), + "msg": "scope insufficient", + }) + if err == nil { + t.Fatal("expected permission error") + } + // Bot mode should still include the scope hint + if !strings.Contains(err.Error(), "insufficient permissions") { + t.Errorf("unexpected error: %v", err) + } +} + +// ── helpers ── + +func isExitError(err error, target **output.ExitError) bool { + ee, ok := err.(*output.ExitError) + if ok && target != nil { + *target = ee + } + return ok +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..ed41d1f0 --- /dev/null +++ b/go.mod @@ -0,0 +1,54 @@ +module github.com/larksuite/cli + +go 1.23.0 + +require ( + github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/gofrs/flock v0.8.1 + github.com/google/uuid v1.6.0 + github.com/larksuite/oapi-sdk-go/v3 v3.5.3 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/smartystreets/goconvey v1.8.1 + github.com/spf13/cobra v1.10.2 + github.com/zalando/go-keyring v0.2.8 + golang.org/x/net v0.33.0 + golang.org/x/sys v0.33.0 + golang.org/x/term v0.27.0 + golang.org/x/text v0.23.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/gopherjs/gopherjs v1.17.2 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/smarty/assertions v1.15.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..b6a807a3 --- /dev/null +++ b/go.sum @@ -0,0 +1,155 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= +github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/app_registration.go b/internal/auth/app_registration.go new file mode 100644 index 00000000..819863d6 --- /dev/null +++ b/internal/auth/app_registration.go @@ -0,0 +1,225 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/larksuite/cli/internal/core" +) + +// AppRegistrationResponse is the response from the app registration begin endpoint. +type AppRegistrationResponse struct { + DeviceCode string + UserCode string + VerificationUri string + VerificationUriComplete string + ExpiresIn int + Interval int +} + +// AppRegistrationResult is the result of a successful app registration poll. +type AppRegistrationResult struct { + ClientID string + ClientSecret string + UserInfo *AppRegUserInfo +} + +// AppRegUserInfo contains user info returned from app registration. +type AppRegUserInfo struct { + OpenID string + TenantBrand string // "feishu" or "lark" +} + +// RequestAppRegistration initiates the app registration device flow. +func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) { + if errOut == nil { + errOut = io.Discard + } + + ep := core.ResolveEndpoints(brand) + regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu + endpoint := regEp.Accounts + "/oauth/v1/app/registration" + + form := url.Values{} + form.Set("action", "begin") + form.Set("archetype", "PersonalAgent") + form.Set("auth_method", "client_secret") + form.Set("request_user_info", "open_id tenant_brand") + + req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("app registration failed: read body: %v", err) + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("app registration failed: HTTP %d – response not JSON", resp.StatusCode) + } + + _, hasError := data["error"] + if resp.StatusCode >= 400 || hasError { + msg := getStr(data, "error_description") + if msg == "" { + msg = getStr(data, "error") + } + if msg == "" { + msg = "Unknown error" + } + return nil, fmt.Errorf("app registration failed: %s", msg) + } + + expiresIn := getInt(data, "expires_in", 300) + interval := getInt(data, "interval", 5) + + userCode := getStr(data, "user_code") + verificationUri := getStr(data, "verification_uri") + verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode) + + return &AppRegistrationResponse{ + DeviceCode: getStr(data, "device_code"), + UserCode: getStr(data, "user_code"), + VerificationUri: verificationUri, + VerificationUriComplete: verificationUriComplete, + ExpiresIn: expiresIn, + Interval: interval, + }, nil +} + +// BuildVerificationURL appends CLI tracking parameters to the verification URL. +func BuildVerificationURL(baseURL, cliVersion string) string { + sep := "&" + if !strings.Contains(baseURL, "?") { + sep = "?" + } + return baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) + + "&ocv=" + url.QueryEscape(cliVersion) + + "&from=cli" +} + +// PollAppRegistration polls the app registration endpoint until the app is created or the flow times out. +// If the result has ClientSecret == "" and UserInfo.TenantBrand == "lark", the caller should +// retry with BrandLark to get the secret from accounts.larksuite.com. +func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) (*AppRegistrationResult, error) { + if errOut == nil { + errOut = io.Discard + } + + const maxPollInterval = 60 + const maxPollAttempts = 200 + + ep := core.ResolveEndpoints(brand) + endpoint := ep.Accounts + "/oauth/v1/app/registration" + deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) + currentInterval := interval + attempts := 0 + + for time.Now().Before(deadline) && attempts < maxPollAttempts { + attempts++ + if ctx.Err() != nil { + return nil, fmt.Errorf("polling was cancelled") + } + + select { + case <-time.After(time.Duration(currentInterval) * time.Second): + case <-ctx.Done(): + return nil, fmt.Errorf("polling was cancelled") + } + + form := url.Values{} + form.Set("action", "poll") + form.Set("device_code", deviceCode) + + req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode())) + if err != nil { + continue + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll network error: %v\n", err) + currentInterval = minInt(currentInterval+1, maxPollInterval) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll read error: %v\n", err) + currentInterval = minInt(currentInterval+1, maxPollInterval) + continue + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll parse error: %v\n", err) + currentInterval = minInt(currentInterval+1, maxPollInterval) + continue + } + + errStr := getStr(data, "error") + + // Success: client_id present + if errStr == "" && getStr(data, "client_id") != "" { + result := &AppRegistrationResult{ + ClientID: getStr(data, "client_id"), + ClientSecret: getStr(data, "client_secret"), + } + if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok { + result.UserInfo = &AppRegUserInfo{ + OpenID: getStr(userInfoRaw, "open_id"), + TenantBrand: getStr(userInfoRaw, "tenant_brand"), + } + } + return result, nil + } + + switch errStr { + case "authorization_pending": + continue + case "slow_down": + currentInterval = minInt(currentInterval+5, maxPollInterval) + fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", currentInterval) + continue + case "access_denied": + return nil, fmt.Errorf("app registration denied by user") + case "expired_token", "invalid_grant": + return nil, fmt.Errorf("device code expired, please try again") + } + + desc := getStr(data, "error_description") + if desc == "" { + desc = errStr + } + if desc == "" { + desc = "Unknown error" + } + return nil, fmt.Errorf("app registration failed: %s", desc) + } + + if attempts >= maxPollAttempts { + fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: max poll attempts (%d) reached\n", maxPollAttempts) + } + return nil, fmt.Errorf("app registration timed out, please try again") +} diff --git a/internal/auth/app_registration_test.go b/internal/auth/app_registration_test.go new file mode 100644 index 00000000..e706a862 --- /dev/null +++ b/internal/auth/app_registration_test.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "github.com/smartystreets/goconvey/convey" +) + +func Test_BuildVerificationURL(t *testing.T) { + t.Run("URL不含问号则添加?分隔符", func(t *testing.T) { + result := BuildVerificationURL("https://example.com/verify", "1.0.0") + convey.Convey("should add ? separator", t, func() { + convey.So(result, convey.ShouldContainSubstring, "?lpv=1.0.0") + convey.So(result, convey.ShouldContainSubstring, "&ocv=1.0.0") + convey.So(result, convey.ShouldContainSubstring, "&from=cli") + convey.So(result, convey.ShouldStartWith, "https://example.com/verify?") + }) + }) + + t.Run("URL已含问号则添加&分隔符", func(t *testing.T) { + result := BuildVerificationURL("https://example.com/verify?code=abc", "2.0.0") + convey.Convey("should add & separator", t, func() { + convey.So(result, convey.ShouldContainSubstring, "&lpv=2.0.0") + convey.So(result, convey.ShouldContainSubstring, "&ocv=2.0.0") + convey.So(result, convey.ShouldContainSubstring, "&from=cli") + convey.So(result, convey.ShouldNotContainSubstring, "?lpv=") + }) + }) +} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go new file mode 100644 index 00000000..c79aaa22 --- /dev/null +++ b/internal/auth/device_flow.go @@ -0,0 +1,287 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/larksuite/cli/internal/core" +) + +// DeviceAuthResponse is the response from the device authorization endpoint. +type DeviceAuthResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationUri string `json:"verification_uri"` + VerificationUriComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// DeviceFlowTokenData contains the token data from a successful device flow. +type DeviceFlowTokenData struct { + AccessToken string + RefreshToken string + ExpiresIn int + RefreshExpiresIn int + Scope string +} + +// DeviceFlowResult is the result of polling the token endpoint. +type DeviceFlowResult struct { + OK bool + Token *DeviceFlowTokenData + Error string + Message string +} + +// OAuthEndpoints contains the OAuth endpoint URLs. +type OAuthEndpoints struct { + DeviceAuthorization string + Token string +} + +// ResolveOAuthEndpoints resolves OAuth endpoint URLs based on brand. +func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints { + ep := core.ResolveEndpoints(brand) + return OAuthEndpoints{ + DeviceAuthorization: ep.Accounts + "/oauth/v1/device_authorization", + Token: ep.Open + "/open-apis/authen/v2/oauth/token", + } +} + +// RequestDeviceAuthorization requests a device authorization code. +func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) { + if errOut == nil { + errOut = io.Discard + } + + endpoints := ResolveOAuthEndpoints(brand) + + if !strings.Contains(scope, "offline_access") { + if scope != "" { + scope = scope + " offline_access" + } else { + scope = "offline_access" + } + } + + basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret)) + + form := url.Values{} + form.Set("client_id", appId) + form.Set("scope", scope) + + req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Basic "+basicAuth) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("Device authorization failed: read body: %v", err) + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("Device authorization failed: HTTP %d – response not JSON", resp.StatusCode) + } + + _, hasError := data["error"] + if resp.StatusCode >= 400 || hasError { + msg := getStr(data, "error_description") + if msg == "" { + msg = getStr(data, "error") + } + if msg == "" { + msg = "Unknown error" + } + return nil, fmt.Errorf("Device authorization failed: %s", msg) + } + + expiresIn := getInt(data, "expires_in", 240) + interval := getInt(data, "interval", 5) + + verificationUri := getStr(data, "verification_uri") + verificationUriComplete := getStr(data, "verification_uri_complete") + if verificationUriComplete == "" { + verificationUriComplete = verificationUri + } + + return &DeviceAuthResponse{ + DeviceCode: getStr(data, "device_code"), + UserCode: getStr(data, "user_code"), + VerificationUri: verificationUri, + VerificationUriComplete: verificationUriComplete, + ExpiresIn: expiresIn, + Interval: interval, + }, nil +} + +// PollDeviceToken polls the token endpoint until authorization completes or times out. +func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult { + if errOut == nil { + errOut = io.Discard + } + + const maxPollInterval = 60 + const maxPollAttempts = 200 + + endpoints := ResolveOAuthEndpoints(brand) + deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) + currentInterval := interval + attempts := 0 + + for time.Now().Before(deadline) && attempts < maxPollAttempts { + attempts++ + if ctx.Err() != nil { + return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Polling was cancelled"} + } + + select { + case <-time.After(time.Duration(currentInterval) * time.Second): + case <-ctx.Done(): + return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Polling was cancelled"} + } + + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + form.Set("device_code", deviceCode) + form.Set("client_id", appId) + form.Set("client_secret", appSecret) + + req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode())) + if err != nil { + continue + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll network error: %v\n", err) + currentInterval = minInt(currentInterval+1, maxPollInterval) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll read error: %v\n", err) + currentInterval = minInt(currentInterval+1, maxPollInterval) + continue + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll parse error: %v\n", err) + currentInterval = minInt(currentInterval+1, maxPollInterval) + continue + } + + errStr := getStr(data, "error") + + if errStr == "" && getStr(data, "access_token") != "" { + fmt.Fprintf(errOut, "[lark-cli] device-flow: token obtained successfully\n") + refreshToken := getStr(data, "refresh_token") + tokenExpiresIn := getInt(data, "expires_in", 7200) + refreshExpiresIn := getInt(data, "refresh_token_expires_in", 604800) + if refreshToken == "" { + fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: no refresh_token in response\n") + refreshExpiresIn = tokenExpiresIn + } + return &DeviceFlowResult{ + OK: true, + Token: &DeviceFlowTokenData{ + AccessToken: getStr(data, "access_token"), + RefreshToken: refreshToken, + ExpiresIn: tokenExpiresIn, + RefreshExpiresIn: refreshExpiresIn, + Scope: getStr(data, "scope"), + }, + } + } + + switch errStr { + case "authorization_pending": + continue + case "slow_down": + currentInterval = minInt(currentInterval+5, maxPollInterval) + fmt.Fprintf(errOut, "[lark-cli] device-flow: slow_down, interval increased to %ds\n", currentInterval) + continue + case "access_denied": + msg := getStr(data, "error_description") + if msg == "" { + msg = "Authorization denied by user" + } + return &DeviceFlowResult{OK: false, Error: "access_denied", Message: msg} + case "expired_token", "invalid_grant": + msg := getStr(data, "error_description") + if msg == "" { + msg = "Device code expired, please try again" + } + return &DeviceFlowResult{OK: false, Error: "expired_token", Message: msg} + } + + desc := getStr(data, "error_description") + if desc == "" { + desc = errStr + } + if desc == "" { + desc = "Unknown error" + } + fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: unexpected error: error=%s, desc=%s\n", errStr, desc) + return &DeviceFlowResult{OK: false, Error: "expired_token", Message: desc} + } + + if attempts >= maxPollAttempts { + fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: max poll attempts (%d) reached\n", maxPollAttempts) + } + return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Authorization timed out, please try again"} +} + +// helpers + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func getStr(m map[string]interface{}, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func getInt(m map[string]interface{}, key string, fallback int) int { + if v, ok := m[key]; ok { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + } + } + return fallback +} diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go new file mode 100644 index 00000000..3cd5dad7 --- /dev/null +++ b/internal/auth/device_flow_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "github.com/larksuite/cli/internal/core" +) + +func TestResolveOAuthEndpoints_Feishu(t *testing.T) { + ep := ResolveOAuthEndpoints(core.BrandFeishu) + if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" { + t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization) + } + if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" { + t.Errorf("Token = %q", ep.Token) + } +} + +func TestResolveOAuthEndpoints_Lark(t *testing.T) { + ep := ResolveOAuthEndpoints(core.BrandLark) + if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" { + t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization) + } + if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" { + t.Errorf("Token = %q", ep.Token) + } +} diff --git a/internal/auth/errors.go b/internal/auth/errors.go new file mode 100644 index 00000000..76bd5995 --- /dev/null +++ b/internal/auth/errors.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "fmt" + + "github.com/larksuite/cli/internal/output" +) + +const ( + LarkErrBlockByPolicy = 21001 // access denied by access control policy + LarkErrBlockByPolicyTryAuth = 21000 // access denied by access control policy; challenge is required to be completed by user in order to gain access +) + +// RefreshTokenRetryable contains error codes that allow one immediate retry. +// All other refresh errors clear the token immediately. +var RefreshTokenRetryable = map[int]bool{ + output.LarkErrRefreshServerError: true, +} + +// TokenRetryCodes contains error codes that allow retry after token refresh. +var TokenRetryCodes = map[int]bool{ + output.LarkErrTokenInvalid: true, + output.LarkErrTokenExpired: true, +} + +// NeedAuthorizationError is thrown when no valid UAT exists. +type NeedAuthorizationError struct { + UserOpenId string +} + +func (e *NeedAuthorizationError) Error() string { + return fmt.Sprintf("need_user_authorization (user: %s)", e.UserOpenId) +} + +// SecurityPolicyError is returned when a request is blocked by access control policies. +type SecurityPolicyError struct { + Code int + Message string + ChallengeURL string + CLIHint string + Err error +} + +func (e *SecurityPolicyError) Error() string { + if e.Err != nil { + return fmt.Sprintf("security policy error [%d]: %s: %v", e.Code, e.Message, e.Err) + } + return fmt.Sprintf("security policy error [%d]: %s", e.Code, e.Message) +} + +func (e *SecurityPolicyError) Unwrap() error { + return e.Err +} diff --git a/internal/auth/scope.go b/internal/auth/scope.go new file mode 100644 index 00000000..6908012b --- /dev/null +++ b/internal/auth/scope.go @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import "strings" + +// MissingScopes returns the elements of required that are absent from storedScope. +// storedScope is a space-separated list of granted scope strings (as stored in the token). +func MissingScopes(storedScope string, required []string) []string { + granted := make(map[string]bool) + for _, s := range strings.Fields(storedScope) { + granted[s] = true + } + var missing []string + for _, s := range required { + if !granted[s] { + missing = append(missing, s) + } + } + return missing +} diff --git a/internal/auth/scope_test.go b/internal/auth/scope_test.go new file mode 100644 index 00000000..b58d0b98 --- /dev/null +++ b/internal/auth/scope_test.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" +) + +func TestMissingScopes(t *testing.T) { + tests := []struct { + name string + storedScope string + required []string + expected []string + }{ + { + name: "all matched", + storedScope: "a b c", + required: []string{"a", "b"}, + expected: nil, + }, + { + name: "partial missing", + storedScope: "a b", + required: []string{"a", "c"}, + expected: []string{"c"}, + }, + { + name: "all missing", + storedScope: "a b", + required: []string{"x", "y"}, + expected: []string{"x", "y"}, + }, + { + name: "empty storedScope", + storedScope: "", + required: []string{"a"}, + expected: []string{"a"}, + }, + { + name: "empty required", + storedScope: "a b", + required: []string{}, + expected: nil, + }, + { + name: "extra whitespace in storedScope", + storedScope: " a b c ", + required: []string{"b"}, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MissingScopes(tt.storedScope, tt.required) + if !sliceEqual(got, tt.expected) { + t.Errorf("MissingScopes(%q, %v) = %v, want %v", tt.storedScope, tt.required, got, tt.expected) + } + }) + } +} + +func sliceEqual(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/auth/token_store.go b/internal/auth/token_store.go new file mode 100644 index 00000000..80883a64 --- /dev/null +++ b/internal/auth/token_store.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/larksuite/cli/internal/keychain" +) + +// StoredUAToken represents a stored user access token. +type StoredUAToken struct { + UserOpenId string `json:"userOpenId"` + AppId string `json:"appId"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` // Unix ms + RefreshExpiresAt int64 `json:"refreshExpiresAt"` // Unix ms + Scope string `json:"scope"` + GrantedAt int64 `json:"grantedAt"` // Unix ms +} + +const refreshAheadMs = 5 * 60 * 1000 // 5 minutes + +func accountKey(appId, userOpenId string) string { + return fmt.Sprintf("%s:%s", appId, userOpenId) +} + +// MaskToken masks a token for safe logging. +func MaskToken(token string) string { + if len(token) <= 8 { + return "****" + } + return "****" + token[len(token)-4:] +} + +// GetStoredToken reads the stored UAT for a given (appId, userOpenId) pair. +func GetStoredToken(appId, userOpenId string) *StoredUAToken { + jsonStr := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId)) + if jsonStr == "" { + return nil + } + var token StoredUAToken + if err := json.Unmarshal([]byte(jsonStr), &token); err != nil { + return nil + } + return &token +} + +// SetStoredToken persists a UAT. +func SetStoredToken(token *StoredUAToken) error { + key := accountKey(token.AppId, token.UserOpenId) + data, err := json.Marshal(token) + if err != nil { + return err + } + return keychain.Set(keychain.LarkCliService, key, string(data)) +} + +// RemoveStoredToken removes a stored UAT. +func RemoveStoredToken(appId, userOpenId string) error { + return keychain.Remove(keychain.LarkCliService, accountKey(appId, userOpenId)) +} + +// TokenStatus determines the freshness of a stored token. +func TokenStatus(token *StoredUAToken) string { + now := time.Now().UnixMilli() + if now < token.ExpiresAt-refreshAheadMs { + return "valid" + } + if now < token.RefreshExpiresAt { + return "needs_refresh" + } + return "expired" +} diff --git a/internal/auth/transport.go b/internal/auth/transport.go new file mode 100644 index 00000000..2a167049 --- /dev/null +++ b/internal/auth/transport.go @@ -0,0 +1,199 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses +// and checks for security policy errors. +type SecurityPolicyTransport struct { + Base http.RoundTripper +} + +func (t *SecurityPolicyTransport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} + +// RoundTrip implements http.RoundTripper. +func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.base().RoundTrip(req) + if err != nil { + if resp != nil && resp.Body != nil { + resp.Body.Close() + } + return nil, err + } + if resp == nil || resp.Body == nil { + return resp, nil + } + + // Only process JSON responses to avoid memory spikes on large files + contentType := strings.ToLower(resp.Header.Get("Content-Type")) + if !strings.Contains(contentType, "application/json") { + return resp, nil + } + + // Read up to 64KB of the body to check for security policy errors + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + if err != nil { + resp.Body.Close() + return nil, fmt.Errorf("failed to read response body in security transport: %w", err) + } + + // Restore the body so it can be read by the caller, preserving streaming capability + resp.Body = struct { + io.Reader + io.Closer + }{ + io.MultiReader(bytes.NewReader(bodyBytes), resp.Body), + resp.Body, + } + + // Try to parse it as JSON + var result map[string]interface{} + if err := json.Unmarshal(bodyBytes, &result); err != nil { + return resp, nil + } + + // 1. Try to handle as MCP (JSON-RPC) format first + if err := t.tryHandleMCPResponse(result); err != nil { + resp.Body.Close() + return nil, err + } + + // 2. Try to handle as OpenAPI error format + if err := t.tryHandleOAPIResponse(result); err != nil { + resp.Body.Close() + return nil, err + } + + return resp, nil +} + +func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interface{}) error { + // MCP (JSON-RPC) response format: + // { + // "error": { + // "code": 21000, + // "message": "...", + // "data": { "challenge_url": "...", "cli_hint": "..." } + // } + // } + errMap, ok := result["error"].(map[string]interface{}) + if !ok { + return nil + } + + code := getInt(errMap, "code", 0) + if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy { + return nil + } + + dataMap, ok := errMap["data"].(map[string]interface{}) + if !ok { + return nil + } + + // Clean up backticks and spaces from challenge_url + challengeUrl := strings.Trim(getStr(dataMap, "challenge_url"), " `") + cliHint := getStr(dataMap, "cli_hint") + msg := getStr(errMap, "message") + + if challengeUrl != "" || cliHint != "" { + // Security validation for challengeUrl + if challengeUrl != "" && !isValidChallengeURL(challengeUrl) { + challengeUrl = "" + } + + if challengeUrl != "" || cliHint != "" { + return &SecurityPolicyError{ + Code: code, + Message: msg, + ChallengeURL: challengeUrl, + CLIHint: cliHint, + } + } + } + + return nil +} + +func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interface{}) error { + // 1. Extract code + code := getInt(result, "code", 0) + + // If code is 0, check if it's already in our error format {"error": {"code": 21000, ...}, "ok": false} + if code == 0 { + if errMap, ok := result["error"].(map[string]interface{}); ok { + code = getInt(errMap, "code", 0) + } + } + + // 2. Check if it's a security policy error + if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy { + return nil + } + + // 3. Extract details + var challengeUrl, cliHint, msg string + if dataMap, ok := result["data"].(map[string]interface{}); ok { + // Standard OAPI format + challengeUrl = getStr(dataMap, "challenge_url") + cliHint = getStr(dataMap, "cli_hint") + msg = getStr(result, "msg") + } else if errMap, ok := result["error"].(map[string]interface{}); ok { + // Already formatted error format (e.g. from internal API or CLI output) + challengeUrl = getStr(errMap, "challenge_url") + cliHint = getStr(errMap, "hint") + msg = getStr(errMap, "message") + } + + // 4. Print and exit if we have enough info + if msg != "" || challengeUrl != "" || cliHint != "" { + // Security validation for challengeUrl + if challengeUrl != "" && !isValidChallengeURL(challengeUrl) { + challengeUrl = "" + } + + if msg != "" || challengeUrl != "" || cliHint != "" { + return &SecurityPolicyError{ + Code: code, + Message: msg, + ChallengeURL: challengeUrl, + CLIHint: cliHint, + } + } + } + + return nil +} + +func isValidChallengeURL(rawURL string) bool { + if rawURL == "" { + return false + } + + u, err := url.Parse(rawURL) + if err != nil { + return false + } + + // 1. Must be https + if u.Scheme != "https" { + return false + } + + return true +} diff --git a/internal/auth/uat_client.go b/internal/auth/uat_client.go new file mode 100644 index 00000000..133c9c7e --- /dev/null +++ b/internal/auth/uat_client.go @@ -0,0 +1,305 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" + + "github.com/gofrs/flock" + "github.com/larksuite/cli/internal/core" +) + +var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func sanitizeID(id string) string { + return safeIDChars.ReplaceAllString(id, "_") +} + +// UATCallOptions contains options for UAT API calls. +type UATCallOptions struct { + UserOpenId string + AppId string + AppSecret string + Domain core.LarkBrand + ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut) +} + +// UATStatus represents the status of a user access token. +type UATStatus struct { + Authorized bool `json:"authorized"` + UserOpenId string `json:"userOpenId"` + Scope string `json:"scope,omitempty"` + ExpiresAt int64 `json:"expiresAt,omitempty"` + RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"` + GrantedAt int64 `json:"grantedAt,omitempty"` + TokenStatus string `json:"tokenStatus,omitempty"` +} + +// NewUATCallOptions creates UATCallOptions from a CLI config. +func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions { + if errOut == nil { + errOut = os.Stderr + } + return UATCallOptions{ + UserOpenId: cfg.UserOpenId, + AppId: cfg.AppID, + AppSecret: cfg.AppSecret, + Domain: cfg.Brand, + ErrOut: errOut, + } +} + +var refreshLocks sync.Map + +// GetValidAccessToken obtains a valid access token for the given user. +func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, error) { + stored := GetStoredToken(opts.AppId, opts.UserOpenId) + if stored == nil { + return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId} + } + + status := TokenStatus(stored) + + if status == "valid" { + return stored.AccessToken, nil + } + + if status == "needs_refresh" { + refreshed, err := refreshWithLock(httpClient, opts, stored) + if err != nil { + return "", err + } + if refreshed == nil { + return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId} + } + return refreshed.AccessToken, nil + } + + // expired + if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil { + if opts.ErrOut != nil { + fmt.Fprintf(opts.ErrOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err) + } else { + fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err) + } + } + return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId} +} + +func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) { + key := fmt.Sprintf("%s:%s", opts.AppId, opts.UserOpenId) + + // 1. Process-level lock (prevents multiple goroutines in the same process) + done := make(chan struct{}) + if existing, loaded := refreshLocks.LoadOrStore(key, done); loaded { + // Another goroutine is already refreshing; wait for it + if ch, ok := existing.(chan struct{}); ok { + <-ch + } else { + // fallback in case of unexpected type + refreshLocks.Delete(key) + } + return GetStoredToken(opts.AppId, opts.UserOpenId), nil + } + + // We own the process lock; done is the channel stored in the map + defer func() { + close(done) + refreshLocks.Delete(key) + }() + + // 2. Cross-process lock using flock + // We use the same underlying storage directory resolution as keychain_other.go + // to ensure locks are isolated properly alongside other sensitive data. + configDir := core.GetConfigDir() + + lockDir := filepath.Join(configDir, "locks") + if err := os.MkdirAll(lockDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create lock directory: %w", err) + } + + safeAppId := sanitizeID(opts.AppId) + safeUserOpenId := sanitizeID(opts.UserOpenId) + lockFile := filepath.Join(lockDir, fmt.Sprintf("refresh_%s_%s.lock", safeAppId, safeUserOpenId)) + fileLock := flock.New(lockFile) + + // Try to acquire the lock, wait if necessary + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + locked, err := fileLock.TryLockContext(ctx, 500*time.Millisecond) + if err != nil { + return nil, fmt.Errorf("failed to acquire cross-process lock: %w", err) + } + if !locked { + return nil, fmt.Errorf("timeout waiting for cross-process lock") + } + defer fileLock.Unlock() + + // 3. Double-checked locking: Check if another process has already refreshed the token + freshStored := GetStoredToken(opts.AppId, opts.UserOpenId) + if freshStored != nil { + status := TokenStatus(freshStored) + if status == "valid" { + // Another process refreshed it, we can just use the new token + if opts.ErrOut != nil { + fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: token already refreshed by another process\n") + } + return freshStored, nil + } + } + + // 4. Actually perform the refresh + return doRefreshToken(httpClient, opts, stored) +} + +func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) { + errOut := opts.ErrOut + if errOut == nil { + errOut = os.Stderr + } + + now := time.Now().UnixMilli() + if now >= stored.RefreshExpiresAt { + fmt.Fprintf(errOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId) + if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove expired token: %v\n", err) + } + return nil, nil + } + + endpoints := ResolveOAuthEndpoints(opts.Domain) + + callEndpoint := func() (map[string]interface{}, error) { + form := url.Values{} + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", stored.RefreshToken) + form.Set("client_id", opts.AppId) + form.Set("client_secret", opts.AppSecret) + + req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("token refresh read error: %v", err) + } + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("token refresh parse error: %v", err) + } + return data, nil + } + + data, err := callEndpoint() + if err != nil { + return nil, err + } + + code := getInt(data, "code", -1) + if code == LarkErrBlockByPolicy || code == LarkErrBlockByPolicyTryAuth { + challengeUrl := getStr(data, "challenge_url") + cliHint := getStr(data, "cli_hint") + msg := getStr(data, "error_description") + + return nil, &SecurityPolicyError{ + Code: code, + Message: msg, + ChallengeURL: challengeUrl, + CLIHint: cliHint, + } + } + + errStr := getStr(data, "error") + + if (code != -1 && code != 0) || errStr != "" { + // Retryable server error: retry once, then clear token on second failure. + if RefreshTokenRetryable[code] { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId) + data, err = callEndpoint() + if err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh retry network error for %s, clearing token\n", opts.UserOpenId) + if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err) + } + return nil, nil + } + code = getInt(data, "code", -1) + errStr = getStr(data, "error") + if (code != -1 && code != 0) || errStr != "" { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed after retry (code=%d) for %s, clearing token\n", code, opts.UserOpenId) + if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err) + } + return nil, nil + } + // Retry succeeded, fall through to parse token below. + } else { + // All other errors: clear token, require re-authorization. + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed (code=%d), clearing token for %s\n", code, opts.UserOpenId) + if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil { + fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err) + } + return nil, nil + } + } + + accessToken := getStr(data, "access_token") + if accessToken == "" { + return nil, fmt.Errorf("Token refresh returned no access_token") + } + + refreshToken := getStr(data, "refresh_token") + if refreshToken == "" { + refreshToken = stored.RefreshToken + } + + expiresIn := getInt(data, "expires_in", 7200) + refreshExpiresIn := getInt(data, "refresh_token_expires_in", 0) + refreshExpiresAt := stored.RefreshExpiresAt + if refreshExpiresIn > 0 { + refreshExpiresAt = now + int64(refreshExpiresIn)*1000 + } + + scope := getStr(data, "scope") + if scope == "" { + scope = stored.Scope + } + + updated := &StoredUAToken{ + UserOpenId: stored.UserOpenId, + AppId: opts.AppId, + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresAt: now + int64(expiresIn)*1000, + RefreshExpiresAt: refreshExpiresAt, + Scope: scope, + GrantedAt: stored.GrantedAt, + } + + if err := SetStoredToken(updated); err != nil { + return nil, err + } + return updated, nil +} diff --git a/internal/auth/uat_client_options_test.go b/internal/auth/uat_client_options_test.go new file mode 100644 index 00000000..2c7d26c1 --- /dev/null +++ b/internal/auth/uat_client_options_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "bytes" + "testing" + + "github.com/larksuite/cli/internal/core" +) + +func TestNewUATCallOptions(t *testing.T) { + cfg := &core.CliConfig{ + AppID: "app123", + AppSecret: "secret", + Brand: core.BrandLark, + UserOpenId: "ou_test", + } + errOut := &bytes.Buffer{} + + opts := NewUATCallOptions(cfg, errOut) + + if opts.AppId != "app123" { + t.Errorf("AppId = %q, want app123", opts.AppId) + } + if opts.AppSecret != "secret" { + t.Errorf("AppSecret = %q, want secret", opts.AppSecret) + } + if opts.Domain != core.BrandLark { + t.Errorf("Domain = %q, want lark", opts.Domain) + } + if opts.UserOpenId != "ou_test" { + t.Errorf("UserOpenId = %q, want ou_test", opts.UserOpenId) + } + if opts.ErrOut != errOut { + t.Error("ErrOut not set correctly") + } +} diff --git a/internal/auth/verify.go b/internal/auth/verify.go new file mode 100644 index 00000000..c10c9f81 --- /dev/null +++ b/internal/auth/verify.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// VerifyUserToken calls /authen/v1/user_info to confirm the token is accepted server-side. +// Returns nil on success or an error describing why the server rejected the token. +func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string) error { + apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/authen/v1/user_info", + SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}, + }, larkcore.WithUserAccessToken(accessToken)) + if err != nil { + return err + } + + var resp struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { + return fmt.Errorf("failed to parse response: %v", err) + } + if resp.Code != 0 { + return fmt.Errorf("[%d] %s", resp.Code, resp.Msg) + } + return nil +} diff --git a/internal/auth/verify_test.go b/internal/auth/verify_test.go new file mode 100644 index 00000000..507d221f --- /dev/null +++ b/internal/auth/verify_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "strings" + "testing" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestVerifyUserToken_TransportError(t *testing.T) { + reg := &httpmock.Registry{} + // Register no stubs — any request will fail with "no stub" error + sdk := lark.NewClient("test-app", "test-secret", + lark.WithLogLevel(larkcore.LogLevelError), + lark.WithHttpClient(httpmock.NewClient(reg)), + ) + + err := VerifyUserToken(context.Background(), sdk, "test-token") + if err == nil { + t.Fatal("expected error from transport failure, got nil") + } +} + +func TestVerifyUserToken(t *testing.T) { + tests := []struct { + name string + body interface{} + wantErr bool + errSubstr string + }{ + { + name: "success", + body: map[string]interface{}{"code": 0, "msg": "ok"}, + wantErr: false, + }, + { + name: "token invalid", + body: map[string]interface{}{"code": 99991668, "msg": "invalid token"}, + wantErr: true, + errSubstr: "[99991668]", + }, + { + name: "non-JSON response", + body: "not json", + wantErr: true, + errSubstr: "invalid character", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + t.Cleanup(func() { reg.Verify(t) }) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/authen/v1/user_info", + Body: tt.body, + }) + + sdk := lark.NewClient("test-app", "test-secret", + lark.WithLogLevel(larkcore.LogLevelError), + lark.WithHttpClient(httpmock.NewClient(reg)), + ) + + err := VerifyUserToken(context.Background(), sdk, "test-token") + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.errSubstr) { + t.Errorf("error %q does not contain %q", err.Error(), tt.errSubstr) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} diff --git a/internal/build/build.go b/internal/build/build.go new file mode 100644 index 00000000..865a475c --- /dev/null +++ b/internal/build/build.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package build + +import "runtime/debug" + +// Version is dynamically set by -ldflags or falls back to module info. +var Version = "DEV" + +// Date is the build date in YYYY-MM-DD format, set by -ldflags. +var Date = "" + +func init() { + if Version == "DEV" { + if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" { + Version = info.Main.Version + } + } + if Version == "" { + Version = "DEV" + } +} diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 00000000..030a0ded --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,270 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package client + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" +) + +// RawApiRequest describes a raw API request. +type RawApiRequest struct { + Method string + URL string + Params map[string]interface{} + Data interface{} + As core.Identity + ExtraOpts []larkcore.RequestOptionFunc // additional SDK request options (e.g. security headers) +} + +// APIClient wraps lark.Client for all Lark Open API calls. +type APIClient struct { + Config *core.CliConfig + SDK *lark.Client // All Lark API calls go through SDK + HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.) + ErrOut io.Writer // debug/progress output +} + +// buildApiReq converts a RawApiRequest into SDK types and collects +// request-specific options (ExtraOpts, URL-based headers). +// Auth is handled separately by DoSDKRequest. +func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []larkcore.RequestOptionFunc) { + queryParams := make(larkcore.QueryParams) + for k, v := range request.Params { + switch val := v.(type) { + case []string: + queryParams[k] = val + case []interface{}: + for _, item := range val { + queryParams.Add(k, fmt.Sprintf("%v", item)) + } + default: + queryParams.Set(k, fmt.Sprintf("%v", v)) + } + } + + apiReq := &larkcore.ApiReq{ + HttpMethod: strings.ToUpper(request.Method), + ApiPath: request.URL, + Body: request.Data, + QueryParams: queryParams, + } + + var opts []larkcore.RequestOptionFunc + opts = append(opts, request.ExtraOpts...) + return apiReq, opts +} + +// DoSDKRequest resolves auth for the given identity and executes a pre-built SDK request. +// This is the shared auth+execute path used by both DoAPI (generic API calls via RawApiRequest) +// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls). +func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) { + var opts []larkcore.RequestOptionFunc + + if as.IsBot() { + req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant} + } else { + req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser} + if c.Config.UserOpenId == "" { + return nil, fmt.Errorf("login required: lark-cli auth login (or use --as bot)") + } + token, err := auth.GetValidAccessToken(c.HTTP, auth.NewUATCallOptions(c.Config, c.ErrOut)) + if err != nil { + return nil, err + } + opts = append(opts, larkcore.WithUserAccessToken(token)) + } + + opts = append(opts, extraOpts...) + return c.SDK.Do(ctx, req, opts...) +} + +// DoAPI executes a raw Lark SDK request and returns the raw *larkcore.ApiResp. +// Unlike CallAPI which always JSON-decodes, DoAPI returns the raw response — suitable +// for file downloads (pass larkcore.WithFileDownload() via request.ExtraOpts) and +// any endpoint whose Content-Type may not be JSON. +func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore.ApiResp, error) { + apiReq, extraOpts := c.buildApiReq(request) + return c.DoSDKRequest(ctx, apiReq, request.As, extraOpts...) +} + +// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse. +// Use DoAPI directly when the response may not be JSON (e.g. file downloads). +func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) { + resp, err := c.DoAPI(ctx, request) + if err != nil { + return nil, err + } + return ParseJSONResponse(resp) +} + +// paginateLoop runs the core pagination loop. For each successful page (code == 0), +// it calls onResult if non-nil. It always accumulates and returns all raw page results. +func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{})) ([]interface{}, error) { + var allResults []interface{} + var pageToken string + page := 0 + pageDelay := opts.PageDelay + if pageDelay == 0 { + pageDelay = 200 + } + + for { + page++ + params := make(map[string]interface{}) + for k, v := range request.Params { + params[k] = v + } + if pageToken != "" { + params["page_token"] = pageToken + } + + fmt.Fprintf(c.ErrOut, "[page %d] fetching...\n", page) + result, err := c.CallAPI(ctx, RawApiRequest{ + Method: request.Method, + URL: request.URL, + Params: params, + Data: request.Data, + As: request.As, + ExtraOpts: request.ExtraOpts, + }) + if err != nil { + if page == 1 { + return nil, err + } + fmt.Fprintf(c.ErrOut, "[page %d] error, stopping pagination\n", page) + break + } + + if resultMap, ok := result.(map[string]interface{}); ok { + code, _ := util.ToFloat64(resultMap["code"]) + if code != 0 { + allResults = append(allResults, result) + if page == 1 { + return allResults, nil + } + fmt.Fprintf(c.ErrOut, "[page %d] API error (code=%.0f), stopping pagination\n", page, code) + break + } + } + + if onResult != nil { + onResult(result) + } + allResults = append(allResults, result) + + pageToken = "" + if resultMap, ok := result.(map[string]interface{}); ok { + if data, ok := resultMap["data"].(map[string]interface{}); ok { + hasMore, _ := data["has_more"].(bool) + if hasMore { + if pt, ok := data["page_token"].(string); ok && pt != "" { + pageToken = pt + } else if pt, ok := data["next_page_token"].(string); ok && pt != "" { + pageToken = pt + } + } + } + } + + if pageToken == "" { + break + } + + if opts.PageLimit > 0 && page >= opts.PageLimit { + fmt.Fprintf(c.ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", opts.PageLimit) + break + } + + if pageDelay > 0 { + time.Sleep(time.Duration(pageDelay) * time.Millisecond) + } + } + return allResults, nil +} + +// PaginateAll fetches all pages and returns a single merged result. +// Use this for formats that need the complete dataset (e.g. JSON). +func (c *APIClient) PaginateAll(ctx context.Context, request RawApiRequest, opts PaginationOptions) (interface{}, error) { + results, err := c.paginateLoop(ctx, request, opts, nil) + if err != nil { + return nil, err + } + if len(results) == 0 { + return map[string]interface{}{}, nil + } + if len(results) == 1 { + return results[0], nil + } + return mergePagedResults(c.ErrOut, results), nil +} + +// StreamPages fetches all pages and streams each page's list items via onItems. +// Returns the last page result (for error checking), whether any list items were found, +// and any network error. Use this for streaming formats (ndjson, table, csv). +func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}), opts PaginationOptions) (result interface{}, hasItems bool, err error) { + totalItems := 0 + results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) { + resultMap, ok := r.(map[string]interface{}) + if !ok { + return + } + data, ok := resultMap["data"].(map[string]interface{}) + if !ok { + return + } + arrayField := output.FindArrayField(data) + if arrayField == "" { + return + } + items, ok := data[arrayField].([]interface{}) + if !ok { + return + } + totalItems += len(items) + onItems(items) + hasItems = true + }) + if loopErr != nil { + return nil, false, loopErr + } + + if hasItems { + fmt.Fprintf(c.ErrOut, "[pagination] streamed %d pages, %d total items\n", len(results), totalItems) + } + + if len(results) > 0 { + return results[len(results)-1], hasItems, nil + } + return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil +} + +// CheckLarkResponse inspects a Lark API response for business-level errors (non-zero code). +// Uses type assertion instead of interface{} == nil to satisfy interface_nil_check lint. +// Returns nil if result is not a map, map is nil, or code is 0. +func CheckLarkResponse(result interface{}) error { + resultMap, ok := result.(map[string]interface{}) + if !ok || resultMap == nil { + return nil + } + code, _ := util.ToFloat64(resultMap["code"]) + if code == 0 { + return nil + } + larkCode := int(code) + msg, _ := resultMap["msg"].(string) + return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"]) +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 00000000..f0419f3a --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,356 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package client + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// roundTripFunc is an adapter to use a function as http.RoundTripper. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } + +// jsonResponse creates an HTTP response with JSON body. +func jsonResponse(body interface{}) *http.Response { + b, _ := json.Marshal(body) + return &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(b)), + } +} + +// newTestAPIClient creates an APIClient with a mock HTTP transport. +func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) { + t.Helper() + errBuf := &bytes.Buffer{} + httpClient := &http.Client{Transport: rt} + sdk := lark.NewClient("test-app", "test-secret", + lark.WithLogLevel(larkcore.LogLevelError), + lark.WithHttpClient(httpClient), + ) + return &APIClient{ + SDK: sdk, + ErrOut: errBuf, + }, errBuf +} + +func TestIsJSONContentType(t *testing.T) { + tests := []struct { + ct string + want bool + }{ + {"application/json", true}, + {"application/json; charset=utf-8", true}, + {"text/json", true}, + {"application/octet-stream", false}, + {"image/png", false}, + {"text/html", false}, + {"", false}, + } + for _, tt := range tests { + if got := IsJSONContentType(tt.ct); got != tt.want { + t.Errorf("IsJSONContentType(%q) = %v, want %v", tt.ct, got, tt.want) + } + } +} + +func TestMimeToExt(t *testing.T) { + tests := []struct { + ct string + want string + }{ + {"image/png", ".png"}, + {"image/jpeg", ".jpg"}, + {"application/pdf", ".pdf"}, + {"text/plain", ".txt"}, + {"application/octet-stream", ".bin"}, + {"", ".bin"}, + } + for _, tt := range tests { + if got := mimeToExt(tt.ct); got != tt.want { + t.Errorf("mimeToExt(%q) = %q, want %q", tt.ct, got, tt.want) + } + } +} + +func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) { + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "tenant_access_token"): + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-token", "expire": 7200, + }), nil + default: + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "user_id": "u123", + "name": "Test User", + }, + }), nil + } + }) + + ac, errBuf := newTestAPIClient(t, rt) + + result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{ + Method: "GET", + URL: "/open-apis/contact/v3/users/u123", + As: "bot", + }, func(items []interface{}) { + t.Error("onItems should not be called for non-batch API") + }, PaginationOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hasItems { + t.Error("expected hasItems=false for non-batch API") + } + if strings.Contains(errBuf.String(), "[pagination] streamed") { + t.Error("expected no pagination summary log for non-batch API") + } + if result == nil { + t.Fatal("expected non-nil result") + } + resultMap, ok := result.(map[string]interface{}) + if !ok { + t.Fatal("expected result to be a map") + } + data, _ := resultMap["data"].(map[string]interface{}) + if data["user_id"] != "u123" { + t.Errorf("expected user_id=u123, got %v", data["user_id"]) + } +} + +func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) { + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "tenant_access_token"): + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-token", "expire": 7200, + }), nil + default: + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}}, + "has_more": false, + }, + }), nil + } + }) + + ac, errBuf := newTestAPIClient(t, rt) + + var streamedItems []interface{} + result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{ + Method: "GET", + URL: "/open-apis/contact/v3/users", + As: "bot", + }, func(items []interface{}) { + streamedItems = append(streamedItems, items...) + }, PaginationOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !hasItems { + t.Error("expected hasItems=true for batch API") + } + if len(streamedItems) != 2 { + t.Errorf("expected 2 streamed items, got %d", len(streamedItems)) + } + if !strings.Contains(errBuf.String(), "[pagination] streamed") { + t.Error("expected pagination summary log for batch API") + } + if result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestPaginateAll_PageLimitStopsPagination(t *testing.T) { + apiCalls := 0 + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "tenant_access_token"): + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-token", "expire": 7200, + }), nil + default: + apiCalls++ + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": apiCalls}}, + "has_more": true, + "page_token": "next", + }, + }), nil + } + }) + + ac, errBuf := newTestAPIClient(t, rt) + + _, err := ac.PaginateAll(context.Background(), RawApiRequest{ + Method: "GET", + URL: "/open-apis/test", + As: "bot", + }, PaginationOptions{PageLimit: 2, PageDelay: 0}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if apiCalls != 2 { + t.Errorf("expected 2 API calls with PageLimit=2, got %d", apiCalls) + } + if !strings.Contains(errBuf.String(), "reached page limit (2), stopping. Use --page-all --page-limit 0 to fetch all pages.") { + t.Errorf("expected page limit log, got: %s", errBuf.String()) + } +} + +func TestBuildApiReq_QueryParams(t *testing.T) { + ac := &APIClient{} + + tests := []struct { + name string + params map[string]interface{} + want larkcore.QueryParams + }{ + { + name: "scalar values", + params: map[string]interface{}{"page_size": 20, "user_id_type": "open_id"}, + want: larkcore.QueryParams{ + "page_size": []string{"20"}, + "user_id_type": []string{"open_id"}, + }, + }, + { + name: "[]interface{} array", + params: map[string]interface{}{"department_ids": []interface{}{"d1", "d2", "d3"}}, + want: larkcore.QueryParams{ + "department_ids": []string{"d1", "d2", "d3"}, + }, + }, + { + name: "[]string array", + params: map[string]interface{}{"statuses": []string{"active", "inactive"}}, + want: larkcore.QueryParams{ + "statuses": []string{"active", "inactive"}, + }, + }, + { + name: "mixed scalar and array", + params: map[string]interface{}{ + "user_id_type": "open_id", + "ids": []interface{}{"id1", "id2"}, + }, + want: larkcore.QueryParams{ + "user_id_type": []string{"open_id"}, + "ids": []string{"id1", "id2"}, + }, + }, + { + name: "empty array", + params: map[string]interface{}{"tags": []interface{}{}}, + want: larkcore.QueryParams{}, + }, + { + name: "nil params", + params: nil, + want: larkcore.QueryParams{}, + }, + { + name: "bool value", + params: map[string]interface{}{"with_bot": true}, + want: larkcore.QueryParams{"with_bot": []string{"true"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiReq, _ := ac.buildApiReq(RawApiRequest{ + Method: "GET", + URL: "/open-apis/test", + Params: tt.params, + }) + got := apiReq.QueryParams + // Check all expected keys exist with correct values + for k, wantVals := range tt.want { + gotVals, ok := got[k] + if !ok { + t.Errorf("missing key %q", k) + continue + } + if len(gotVals) != len(wantVals) { + t.Errorf("key %q: got %d values %v, want %d values %v", k, len(gotVals), gotVals, len(wantVals), wantVals) + continue + } + for i := range wantVals { + if gotVals[i] != wantVals[i] { + t.Errorf("key %q[%d]: got %q, want %q", k, i, gotVals[i], wantVals[i]) + } + } + } + // Check no unexpected keys + for k := range got { + if _, ok := tt.want[k]; !ok { + t.Errorf("unexpected key %q with values %v", k, got[k]) + } + } + }) + } +} + +func TestPaginateAll_NoStreamSummaryLog(t *testing.T) { + rt := roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch { + case strings.Contains(req.URL.Path, "tenant_access_token"): + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-token", "expire": 7200, + }), nil + default: + return jsonResponse(map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "1"}}, + "has_more": false, + }, + }), nil + } + }) + + ac, errBuf := newTestAPIClient(t, rt) + + result, err := ac.PaginateAll(context.Background(), RawApiRequest{ + Method: "GET", + URL: "/open-apis/contact/v3/users", + As: "bot", + }, PaginationOptions{}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if strings.Contains(errBuf.String(), "[pagination] streamed") { + t.Error("expected no streaming summary log from PaginateAll") + } + if result == nil { + t.Fatal("expected non-nil result") + } +} diff --git a/internal/client/pagination.go b/internal/client/pagination.go new file mode 100644 index 00000000..4c12979d --- /dev/null +++ b/internal/client/pagination.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package client + +import ( + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" +) + +// PaginationOptions contains pagination control options. +type PaginationOptions struct { + PageLimit int // max pages to fetch; 0 = unlimited (default: 10) + PageDelay int // ms, default 200 +} + +func mergePagedResults(w io.Writer, results []interface{}) interface{} { + if len(results) == 0 { + return map[string]interface{}{} + } + + firstMap, ok := results[0].(map[string]interface{}) + if !ok { + return map[string]interface{}{"pages": results} + } + + data, ok := firstMap["data"].(map[string]interface{}) + if !ok { + return map[string]interface{}{"pages": results} + } + + arrayField := output.FindArrayField(data) + if arrayField == "" { + return map[string]interface{}{"pages": results} + } + + var merged []interface{} + for _, r := range results { + if rm, ok := r.(map[string]interface{}); ok { + if d, ok := rm["data"].(map[string]interface{}); ok { + if items, ok := d[arrayField].([]interface{}); ok { + merged = append(merged, items...) + } + } + } + } + + fmt.Fprintf(w, "[pagination] merged %d pages, %d total items\n", len(results), len(merged)) + + mergedData := make(map[string]interface{}) + for k, v := range data { + mergedData[k] = v + } + mergedData[arrayField] = merged + mergedData["has_more"] = false + delete(mergedData, "page_token") + delete(mergedData, "next_page_token") + + result := make(map[string]interface{}) + for k, v := range firstMap { + result[k] = v + } + result["data"] = mergedData + + return result +} diff --git a/internal/client/response.go b/internal/client/response.go new file mode 100644 index 00000000..0a76d57c --- /dev/null +++ b/internal/client/response.go @@ -0,0 +1,188 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" +) + +// ── Response routing ── + +// ResponseOptions configures how HandleResponse routes a raw API response. +type ResponseOptions struct { + OutputPath string // --output flag; "" = auto-detect + Format output.Format // output format for JSON responses + Out io.Writer // stdout + ErrOut io.Writer // stderr + // CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse. + CheckError func(interface{}) error +} + +// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output: +// 1. If Content-Type is JSON, check for business errors first (even with --output). +// 2. If --output is set and response is not a JSON error, save to file. +// 3. If Content-Type is non-JSON and no --output, auto-save binary to file. +func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { + ct := resp.Header.Get("Content-Type") + check := opts.CheckError + if check == nil { + check = CheckLarkResponse + } + + // Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly + // instead of falling through to the binary-save path. + if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" { + body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(resp.RawBody)), 500) + return output.Errorf(httpExitCode(resp.StatusCode), "http_error", "HTTP %d: %s", resp.StatusCode, body) + } + + // JSON responses: always check for business errors before saving. + if IsJSONContentType(ct) || ct == "" { + result, err := ParseJSONResponse(resp) + if err != nil { + return output.ErrNetwork("API call failed: %v", err) + } + if apiErr := check(result); apiErr != nil { + return apiErr + } + if opts.OutputPath != "" { + return saveAndPrint(resp, opts.OutputPath, opts.Out) + } + output.FormatValue(opts.Out, result, opts.Format) + return nil + } + + // Non-JSON (binary) responses. + if opts.OutputPath != "" { + return saveAndPrint(resp, opts.OutputPath, opts.Out) + } + + // No --output: auto-save with derived filename. + meta, err := SaveResponse(resp, ResolveFilename(resp)) + if err != nil { + return output.Errorf(output.ExitInternal, "file_error", "%s", err) + } + fmt.Fprintf(opts.ErrOut, "binary response detected (Content-Type: %s), saved to file\n", ct) + output.PrintJson(opts.Out, meta) + return nil +} + +func saveAndPrint(resp *larkcore.ApiResp, path string, w io.Writer) error { + meta, err := SaveResponse(resp, path) + if err != nil { + return output.Errorf(output.ExitInternal, "file_error", "%s", err) + } + output.PrintJson(w, meta) + return nil +} + +// ── JSON helpers ── + +// IsJSONContentType reports whether the Content-Type header indicates a JSON response. +func IsJSONContentType(ct string) bool { + return strings.Contains(ct, "application/json") || strings.Contains(ct, "text/json") +} + +// ParseJSONResponse decodes a raw SDK response body as JSON. +// CallAPI and HandleResponse both delegate to this function. +func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) { + var result interface{} + dec := json.NewDecoder(bytes.NewReader(resp.RawBody)) + dec.UseNumber() + if err := dec.Decode(&result); err != nil { + return nil, fmt.Errorf("response parse error: %v (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500)) + } + return result, nil +} + +// ── File saving ── + +// SaveResponse writes an API response body to the given outputPath and returns metadata. +func SaveResponse(resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) { + safePath, err := validate.SafeOutputPath(outputPath) + if err != nil { + return nil, fmt.Errorf("unsafe output path: %s", err) + } + + if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil { + return nil, fmt.Errorf("create directory: %s", err) + } + + if err := validate.AtomicWrite(safePath, resp.RawBody, 0644); err != nil { + return nil, fmt.Errorf("cannot write file: %s", err) + } + + return map[string]interface{}{ + "saved_path": safePath, + "size_bytes": len(resp.RawBody), + "content_type": resp.Header.Get("Content-Type"), + }, nil +} + +// ResolveFilename picks a filename from the response headers. +// Priority: Content-Disposition filename > Content-Type extension > "download.bin". +func ResolveFilename(resp *larkcore.ApiResp) string { + if name := larkcore.FileNameByHeader(resp.Header); name != "" { + return name + } + return "download" + mimeToExt(resp.Header.Get("Content-Type")) +} + +// mimeToExt maps a Content-Type to a file extension (with leading dot). +func mimeToExt(ct string) string { + if ct == "" { + return ".bin" + } + mediaType, _, _ := mime.ParseMediaType(ct) + switch mediaType { + case "application/pdf": + return ".pdf" + case "image/png": + return ".png" + case "image/jpeg": + return ".jpg" + case "image/gif": + return ".gif" + case "text/plain": + return ".txt" + case "text/csv": + return ".csv" + case "text/html": + return ".html" + case "application/zip": + return ".zip" + case "application/xml", "text/xml": + return ".xml" + case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": + return ".xlsx" + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + return ".docx" + case "application/vnd.openxmlformats-officedocument.presentationml.presentation": + return ".pptx" + default: + return ".bin" + } +} + +// httpExitCode maps HTTP status ranges to CLI exit codes: +// 5xx → ExitNetwork (server error), 4xx → ExitAPI (client error). +func httpExitCode(status int) int { + if status >= 500 { + return output.ExitNetwork + } + return output.ExitAPI +} diff --git a/internal/client/response_test.go b/internal/client/response_test.go new file mode 100644 index 00000000..8bfc6f16 --- /dev/null +++ b/internal/client/response_test.go @@ -0,0 +1,334 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package client + +import ( + "bytes" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" +) + +func newApiResp(body []byte, headers map[string]string) *larkcore.ApiResp { + return newApiRespWithStatus(200, body, headers) +} + +func newApiRespWithStatus(status int, body []byte, headers map[string]string) *larkcore.ApiResp { + h := http.Header{} + for k, v := range headers { + h.Set(k, v) + } + return &larkcore.ApiResp{ + StatusCode: status, + Header: h, + RawBody: body, + } +} + +func TestIsJSONContentType_Extended(t *testing.T) { + tests := []struct { + ct string + want bool + }{ + {"application/json", true}, + {"application/json; charset=utf-8", true}, + {"text/json", true}, + {"application/octet-stream", false}, + {"", false}, + } + for _, tt := range tests { + if got := IsJSONContentType(tt.ct); got != tt.want { + t.Errorf("IsJSONContentType(%q) = %v, want %v", tt.ct, got, tt.want) + } + } +} + +func TestParseJSONResponse(t *testing.T) { + body := []byte(`{"code":0,"msg":"ok","data":{"id":"123"}}`) + resp := newApiResp(body, map[string]string{"Content-Type": "application/json"}) + result, err := ParseJSONResponse(resp) + if err != nil { + t.Fatalf("ParseJSONResponse failed: %v", err) + } + m, ok := result.(map[string]interface{}) + if !ok { + t.Fatal("expected map result") + } + if m["msg"] != "ok" { + t.Errorf("expected msg=ok, got %v", m["msg"]) + } +} + +func TestParseJSONResponse_Invalid(t *testing.T) { + resp := newApiResp([]byte(`not json`), map[string]string{"Content-Type": "application/json"}) + _, err := ParseJSONResponse(resp) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestResolveFilename(t *testing.T) { + tests := []struct { + name string + headers map[string]string + want string + }{ + { + "from content-type pdf", + map[string]string{"Content-Type": "application/pdf"}, + "download.pdf", + }, + { + "from content-type png", + map[string]string{"Content-Type": "image/png"}, + "download.png", + }, + { + "unknown type", + map[string]string{"Content-Type": "application/octet-stream"}, + "download.bin", + }, + { + "empty content-type", + map[string]string{}, + "download.bin", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resp := newApiResp([]byte("data"), tt.headers) + got := ResolveFilename(resp) + if got != tt.want { + t.Errorf("ResolveFilename() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestMimeToExt_Extended(t *testing.T) { + tests := []struct { + ct string + want string + }{ + {"application/pdf", ".pdf"}, + {"image/png", ".png"}, + {"image/jpeg", ".jpg"}, + {"image/gif", ".gif"}, + {"text/plain", ".txt"}, + {"text/csv", ".csv"}, + {"text/html", ".html"}, + {"application/zip", ".zip"}, + {"application/xml", ".xml"}, + {"text/xml", ".xml"}, + {"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"}, + {"application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx"}, + {"application/vnd.openxmlformats-officedocument.presentationml.presentation", ".pptx"}, + {"application/octet-stream", ".bin"}, + {"", ".bin"}, + } + for _, tt := range tests { + if got := mimeToExt(tt.ct); got != tt.want { + t.Errorf("mimeToExt(%q) = %q, want %q", tt.ct, got, tt.want) + } + } +} + +func TestSaveResponse(t *testing.T) { + dir := t.TempDir() + origWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(origWd) + + body := []byte("hello binary data") + resp := newApiResp(body, map[string]string{"Content-Type": "application/octet-stream"}) + + meta, err := SaveResponse(resp, "test_output.bin") + if err != nil { + t.Fatalf("SaveResponse failed: %v", err) + } + if meta["size_bytes"] != len(body) { + t.Errorf("expected size_bytes=%d, got %v", len(body), meta["size_bytes"]) + } + + savedPath, _ := meta["saved_path"].(string) + data, err := os.ReadFile(savedPath) + if err != nil { + t.Fatalf("read saved file: %v", err) + } + if !bytes.Equal(data, body) { + t.Errorf("saved content mismatch") + } +} + +func TestSaveResponse_CreatesDir(t *testing.T) { + dir := t.TempDir() + origWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(origWd) + + resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"}) + + meta, err := SaveResponse(resp, filepath.Join("sub", "deep", "out.bin")) + if err != nil { + t.Fatalf("SaveResponse with nested dir failed: %v", err) + } + savedPath, _ := meta["saved_path"].(string) + if _, err := os.Stat(savedPath); err != nil { + t.Errorf("expected file to exist at %s", savedPath) + } +} + +func TestHandleResponse_JSON(t *testing.T) { + body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`) + resp := newApiResp(body, map[string]string{"Content-Type": "application/json"}) + + var out bytes.Buffer + var errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{ + Out: &out, + ErrOut: &errOut, + }) + if err != nil { + t.Fatalf("HandleResponse failed: %v", err) + } + if !bytes.Contains(out.Bytes(), []byte(`"code"`)) { + t.Errorf("expected JSON output, got: %s", out.String()) + } +} + +func TestHandleResponse_JSONWithError(t *testing.T) { + body := []byte(`{"code":99991400,"msg":"invalid token"}`) + resp := newApiResp(body, map[string]string{"Content-Type": "application/json"}) + + var out bytes.Buffer + var errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{ + Out: &out, + ErrOut: &errOut, + }) + if err == nil { + t.Error("expected error for non-zero code") + } +} + +func TestHandleResponse_BinaryAutoSave(t *testing.T) { + dir := t.TempDir() + origWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(origWd) + + resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"}) + + var out bytes.Buffer + var errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{ + Out: &out, + ErrOut: &errOut, + }) + if err != nil { + t.Fatalf("HandleResponse binary failed: %v", err) + } + if !bytes.Contains(errOut.Bytes(), []byte("binary response detected")) { + t.Errorf("expected binary detection message, got: %s", errOut.String()) + } +} + +func TestHandleResponse_BinaryWithOutput(t *testing.T) { + dir := t.TempDir() + origWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(origWd) + + resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"}) + + var out bytes.Buffer + var errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{ + OutputPath: "out.png", + Out: &out, + ErrOut: &errOut, + }) + if err != nil { + t.Fatalf("HandleResponse with output path failed: %v", err) + } + data, _ := os.ReadFile("out.png") + if string(data) != "PNG DATA" { + t.Errorf("expected saved PNG DATA, got: %s", data) + } +} + +func TestHandleResponse_NonJSONError_404(t *testing.T) { + resp := newApiRespWithStatus(404, []byte("404 page not found"), map[string]string{"Content-Type": "text/plain"}) + + var out, errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut}) + if err == nil { + t.Fatal("expected error for 404 text/plain") + } + got := err.Error() + if !strings.Contains(got, "HTTP 404") || !strings.Contains(got, "404 page not found") { + t.Errorf("expected 'HTTP 404: 404 page not found', got: %s", got) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI { + t.Errorf("expected ExitAPI (%d) for 4xx, got code: %d", output.ExitAPI, exitErr.Code) + } +} + +func TestHandleResponse_NonJSONError_502(t *testing.T) { + resp := newApiRespWithStatus(502, []byte("Bad Gateway"), map[string]string{"Content-Type": "text/html"}) + + var out, errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut}) + if err == nil { + t.Fatal("expected error for 502 text/html") + } + got := err.Error() + if !strings.Contains(got, "HTTP 502") || !strings.Contains(got, "Bad Gateway") { + t.Errorf("expected 'HTTP 502' and 'Bad Gateway' in error, got: %s", got) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Code != output.ExitNetwork { + t.Errorf("expected ExitNetwork (%d) for 5xx, got code: %d", output.ExitNetwork, exitErr.Code) + } +} + +func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) { + dir := t.TempDir() + origWd, _ := os.Getwd() + os.Chdir(dir) + defer os.Chdir(origWd) + + resp := newApiRespWithStatus(200, []byte("plain text file content"), map[string]string{"Content-Type": "text/plain"}) + + var out, errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut}) + if err != nil { + t.Fatalf("expected no error for 200 text/plain, got: %v", err) + } + if !strings.Contains(errOut.String(), "binary response detected") { + t.Errorf("expected binary detection message, got: %s", errOut.String()) + } +} + +func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) { + body := []byte(`{"code":99991400,"msg":"invalid token"}`) + resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"}) + + var out, errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut}) + if err == nil { + t.Fatal("expected error for 403 JSON with non-zero code") + } + if !strings.Contains(err.Error(), "99991400") { + t.Errorf("expected lark error code in message, got: %s", err.Error()) + } +} diff --git a/internal/cmdutil/annotations.go b/internal/cmdutil/annotations.go new file mode 100644 index 00000000..1aacec7e --- /dev/null +++ b/internal/cmdutil/annotations.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import "github.com/spf13/cobra" + +const skipAuthCheckKey = "skipAuthCheck" + +// DisableAuthCheck marks a command (and all its children) as not requiring auth. +func DisableAuthCheck(cmd *cobra.Command) { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[skipAuthCheckKey] = "true" +} + +// IsAuthCheckDisabled returns true if the command or any ancestor has auth check disabled. +func IsAuthCheckDisabled(cmd *cobra.Command) bool { + for c := cmd; c != nil; c = c.Parent() { + if c.Annotations != nil && c.Annotations[skipAuthCheckKey] == "true" { + return true + } + } + return false +} diff --git a/internal/cmdutil/annotations_test.go b/internal/cmdutil/annotations_test.go new file mode 100644 index 00000000..6ee5bab1 --- /dev/null +++ b/internal/cmdutil/annotations_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestDisableAuthCheck(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + if IsAuthCheckDisabled(cmd) { + t.Error("expected auth check to be enabled by default") + } + + DisableAuthCheck(cmd) + if !IsAuthCheckDisabled(cmd) { + t.Error("expected auth check to be disabled after DisableAuthCheck") + } +} + +func TestIsAuthCheckDisabled_Inheritance(t *testing.T) { + parent := &cobra.Command{Use: "parent"} + child := &cobra.Command{Use: "child"} + parent.AddCommand(child) + + if IsAuthCheckDisabled(child) { + t.Error("expected child auth check enabled before parent annotation") + } + + DisableAuthCheck(parent) + if !IsAuthCheckDisabled(child) { + t.Error("expected child to inherit disabled auth check from parent") + } +} + +func TestIsAuthCheckDisabled_NoInheritanceUpward(t *testing.T) { + parent := &cobra.Command{Use: "parent"} + child := &cobra.Command{Use: "child"} + parent.AddCommand(child) + + DisableAuthCheck(child) + if IsAuthCheckDisabled(parent) { + t.Error("parent should not inherit disabled auth check from child") + } + if !IsAuthCheckDisabled(child) { + t.Error("child should have disabled auth check") + } +} diff --git a/internal/cmdutil/dryrun.go b/internal/cmdutil/dryrun.go new file mode 100644 index 00000000..57302a60 --- /dev/null +++ b/internal/cmdutil/dryrun.go @@ -0,0 +1,252 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "sort" + "strings" + + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" +) + +// DryRunAPICall describes a single API call in dry-run output. +type DryRunAPICall struct { + Desc string `json:"desc,omitempty"` + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params,omitempty"` + Body interface{} `json:"body,omitempty"` +} + +// DryRunAPI is the builder and result type for dry-run output. +// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them. +type DryRunAPI struct { + desc string + calls []DryRunAPICall + extra map[string]interface{} +} + +func NewDryRunAPI() *DryRunAPI { + return &DryRunAPI{extra: map[string]interface{}{}} +} + +// --- HTTP method builders (add a call, return self for chaining) --- + +func (d *DryRunAPI) GET(url string) *DryRunAPI { + d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url}) + return d +} + +func (d *DryRunAPI) POST(url string) *DryRunAPI { + d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url}) + return d +} + +func (d *DryRunAPI) PUT(url string) *DryRunAPI { + d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url}) + return d +} + +func (d *DryRunAPI) DELETE(url string) *DryRunAPI { + d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url}) + return d +} + +func (d *DryRunAPI) PATCH(url string) *DryRunAPI { + d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url}) + return d +} + +// Body sets the request body on the last added call. +func (d *DryRunAPI) Body(body interface{}) *DryRunAPI { + if n := len(d.calls); n > 0 { + d.calls[n-1].Body = body + } + return d +} + +// Params sets query parameters on the last added call. +func (d *DryRunAPI) Params(params map[string]interface{}) *DryRunAPI { + if n := len(d.calls); n > 0 { + d.calls[n-1].Params = params + } + return d +} + +// Desc sets a description on the last added call. +// If no calls exist yet, sets the top-level description. +func (d *DryRunAPI) Desc(desc string) *DryRunAPI { + if n := len(d.calls); n > 0 { + d.calls[n-1].Desc = desc + } else { + d.desc = desc + } + return d +} + +// Set adds an extra context field. Values are also used to resolve :key placeholders in URLs. +func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI { + d.extra[key] = value + return d +} + +// resolveURL replaces :key placeholders in url with path-escaped values from extra. +func (d *DryRunAPI) resolveURL(rawURL string) string { + for k, v := range d.extra { + rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v))) + } + return rawURL +} + +// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}. +func (d *DryRunAPI) MarshalJSON() ([]byte, error) { + resolved := make([]DryRunAPICall, len(d.calls)) + for i, c := range d.calls { + resolved[i] = DryRunAPICall{ + Desc: c.Desc, + Method: c.Method, + URL: d.resolveURL(c.URL), + Params: c.Params, + Body: c.Body, + } + } + m := make(map[string]interface{}, len(d.extra)+2) + if d.desc != "" { + m["description"] = d.desc + } + m["api"] = resolved + for k, v := range d.extra { + m[k] = v + } + return json.Marshal(m) +} + +// Format renders the dry-run output as plain text for AI/human consumption. +func (d *DryRunAPI) Format() string { + var b strings.Builder + + if d.desc != "" { + b.WriteString("# ") + b.WriteString(d.desc) + b.WriteByte('\n') + } + + for i, c := range d.calls { + if i > 0 || d.desc != "" { + b.WriteByte('\n') + } + if c.Desc != "" { + b.WriteString("# ") + b.WriteString(c.Desc) + b.WriteByte('\n') + } + + u := d.resolveURL(c.URL) + if len(c.Params) > 0 { + u += "?" + encodeParams(c.Params) + } + + method := c.Method + if method == "" { + method = "GET" + } + b.WriteString(method) + b.WriteByte(' ') + b.WriteString(u) + b.WriteByte('\n') + + if !util.IsNil(c.Body) { + j, _ := json.Marshal(c.Body) + b.WriteString(" ") + b.Write(j) + b.WriteByte('\n') + } + } + + if len(d.calls) == 0 && len(d.extra) > 0 { + if d.desc != "" { + b.WriteByte('\n') + } + keys := make([]string, 0, len(d.extra)) + for k := range d.extra { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + sv := dryRunFormatValue(d.extra[k]) + if sv == "" { + continue + } + b.WriteString(k) + b.WriteString(": ") + b.WriteString(sv) + b.WriteByte('\n') + } + } + + return b.String() +} + +func dryRunFormatValue(v interface{}) string { + switch val := v.(type) { + case string: + return val + case nil: + return "" + default: + j, _ := json.Marshal(val) + return string(j) + } +} + +func encodeParams(params map[string]interface{}) string { + vals := url.Values{} + for k, v := range params { + vals.Set(k, fmt.Sprintf("%v", v)) + } + return vals.Encode() +} + +// PrintDryRun outputs a standardised dry-run summary using DryRunAPI. +// When format is "pretty", outputs human-readable text; otherwise JSON. +func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error { + dr := NewDryRunAPI() + switch request.Method { + case "POST": + dr.POST(request.URL) + case "PUT": + dr.PUT(request.URL) + case "PATCH": + dr.PATCH(request.URL) + case "DELETE": + dr.DELETE(request.URL) + default: + dr.GET(request.URL) + } + if len(request.Params) > 0 { + dr.Params(request.Params) + } + if !util.IsNil(request.Data) { + dr.Body(request.Data) + } + dr.Set("as", string(request.As)) + dr.Set("appId", config.AppID) + if config.UserOpenId != "" { + dr.Set("userOpenId", config.UserOpenId) + } + fmt.Fprintln(w, "=== Dry Run ===") + if format == "pretty" { + fmt.Fprint(w, dr.Format()) + } else { + output.PrintJson(w, dr) + } + return nil +} diff --git a/internal/cmdutil/dryrun_test.go b/internal/cmdutil/dryrun_test.go new file mode 100644 index 00000000..35470d57 --- /dev/null +++ b/internal/cmdutil/dryrun_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/core" +) + +func TestDryRunAPI_SingleGET(t *testing.T) { + dr := NewDryRunAPI(). + Desc("list calendars"). + GET("/open-apis/calendar/v4/calendars") + + text := dr.Format() + if !strings.Contains(text, "# list calendars") { + t.Errorf("expected description in text output, got: %s", text) + } + if !strings.Contains(text, "GET /open-apis/calendar/v4/calendars") { + t.Errorf("expected GET line in text output, got: %s", text) + } +} + +func TestDryRunAPI_WithParams(t *testing.T) { + dr := NewDryRunAPI(). + GET("/open-apis/test"). + Params(map[string]interface{}{"page_size": 20}) + + text := dr.Format() + if !strings.Contains(text, "page_size=20") { + t.Errorf("expected query params in text output, got: %s", text) + } +} + +func TestDryRunAPI_WithBody(t *testing.T) { + dr := NewDryRunAPI(). + POST("/open-apis/test"). + Body(map[string]interface{}{"title": "hello"}) + + text := dr.Format() + if !strings.Contains(text, "POST /open-apis/test") { + t.Errorf("expected POST line, got: %s", text) + } + if !strings.Contains(text, `"title"`) { + t.Errorf("expected body in output, got: %s", text) + } +} + +func TestDryRunAPI_ResolveURL(t *testing.T) { + dr := NewDryRunAPI(). + GET("/open-apis/calendar/v4/calendars/:calendar_id/events"). + Set("calendar_id", "cal_abc123") + + text := dr.Format() + if !strings.Contains(text, "cal_abc123") { + t.Errorf("expected resolved calendar_id in URL, got: %s", text) + } + if strings.Contains(text, ":calendar_id") { + t.Errorf("expected placeholder to be resolved, got: %s", text) + } +} + +func TestDryRunAPI_MarshalJSON(t *testing.T) { + dr := NewDryRunAPI(). + Desc("test api"). + GET("/open-apis/test"). + Set("as", "user") + + data, err := json.Marshal(dr) + if err != nil { + t.Fatalf("MarshalJSON failed: %v", err) + } + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if m["description"] != "test api" { + t.Errorf("expected description, got: %v", m["description"]) + } + if m["as"] != "user" { + t.Errorf("expected as=user, got: %v", m["as"]) + } + api, ok := m["api"].([]interface{}) + if !ok || len(api) != 1 { + t.Errorf("expected 1 api call, got: %v", m["api"]) + } +} + +func TestDryRunAPI_MultipleCalls(t *testing.T) { + dr := NewDryRunAPI(). + GET("/open-apis/first").Desc("step 1"). + POST("/open-apis/second").Desc("step 2") + + text := dr.Format() + if !strings.Contains(text, "# step 1") || !strings.Contains(text, "# step 2") { + t.Errorf("expected both step descriptions, got: %s", text) + } + if !strings.Contains(text, "GET /open-apis/first") || !strings.Contains(text, "POST /open-apis/second") { + t.Errorf("expected both calls, got: %s", text) + } +} + +func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) { + dr := NewDryRunAPI(). + Desc("info only"). + Set("calendar_id", "cal_123"). + Set("summary", "My Calendar") + + text := dr.Format() + if !strings.Contains(text, "calendar_id: cal_123") { + t.Errorf("expected extra field, got: %s", text) + } + if !strings.Contains(text, "summary: My Calendar") { + t.Errorf("expected extra field, got: %s", text) + } +} + +func TestPrintDryRun_JSON(t *testing.T) { + var buf bytes.Buffer + err := PrintDryRun(&buf, client.RawApiRequest{ + Method: "GET", + URL: "/open-apis/test", + As: "user", + }, &core.CliConfig{AppID: "app123"}, "json") + if err != nil { + t.Fatalf("PrintDryRun failed: %v", err) + } + out := buf.String() + if !strings.Contains(out, "=== Dry Run ===") { + t.Errorf("expected header, got: %s", out) + } + if !strings.Contains(out, "app123") { + t.Errorf("expected appId in output, got: %s", out) + } +} + +func TestPrintDryRun_Pretty(t *testing.T) { + var buf bytes.Buffer + err := PrintDryRun(&buf, client.RawApiRequest{ + Method: "POST", + URL: "/open-apis/test", + Data: map[string]interface{}{"key": "val"}, + As: "bot", + }, &core.CliConfig{AppID: "app456"}, "pretty") + if err != nil { + t.Fatalf("PrintDryRun failed: %v", err) + } + out := buf.String() + if !strings.Contains(out, "POST /open-apis/test") { + t.Errorf("expected POST line in pretty output, got: %s", out) + } +} + +func TestDryRunFormatValue(t *testing.T) { + tests := []struct { + name string + v interface{} + want string + }{ + {"string", "hello", "hello"}, + {"nil", nil, ""}, + {"number", 42, "42"}, + {"bool", true, "true"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := dryRunFormatValue(tt.v); got != tt.want { + t.Errorf("dryRunFormatValue(%v) = %q, want %q", tt.v, got, tt.want) + } + }) + } +} diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go new file mode 100644 index 00000000..a53d0868 --- /dev/null +++ b/internal/cmdutil/factory.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "fmt" + "io" + "net/http" + "os" + "strings" + + lark "github.com/larksuite/oapi-sdk-go/v3" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/output" +) + +// ResolveConfig returns Config() for bot identity, or AuthConfig() for user identity. +func (f *Factory) ResolveConfig(as core.Identity) (*core.CliConfig, error) { + if as.IsBot() { + return f.Config() + } + return f.AuthConfig() +} + +// Factory holds shared dependencies injected into every command. +// All function fields are lazily initialized and cached after first call. +// In tests, replace any field to stub out external dependencies. +type Factory struct { + Config func() (*core.CliConfig, error) // lazily loads app config (credentials, brand, defaultAs) + AuthConfig func() (*core.CliConfig, error) // like Config but also requires a logged-in user + HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers) + LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls + IOStreams *IOStreams // stdin/stdout/stderr streams + + Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests) + IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected + ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call +} + +// ResolveAs returns the effective identity type. +// If the user explicitly passed --as, use that value; otherwise use the configured default. +// When the value is "auto" (or unset), auto-detect based on login state. +func (f *Factory) ResolveAs(cmd *cobra.Command, flagAs core.Identity) core.Identity { + f.IdentityAutoDetected = false + if cmd != nil && cmd.Flags().Changed("as") { + if flagAs != "auto" { + f.ResolvedIdentity = flagAs + return flagAs + } + // --as auto: fall through to auto-detect + } else if defaultAs := f.resolveDefaultAs(); defaultAs != "" && defaultAs != "auto" { + f.ResolvedIdentity = core.Identity(defaultAs) + return f.ResolvedIdentity + } + // Auto-detect based on login state + f.IdentityAutoDetected = true + result := f.autoDetectIdentity() + f.ResolvedIdentity = result + return result +} + +// resolveDefaultAs returns the configured default identity: env var > config file. +func (f *Factory) resolveDefaultAs() string { + if v := os.Getenv("LARKSUITE_CLI_DEFAULT_AS"); v != "" { + return v + } + if cfg, err := f.Config(); err == nil { + return cfg.DefaultAs + } + return "" +} + +// autoDetectIdentity checks the login state and returns user if logged in, bot otherwise. +func (f *Factory) autoDetectIdentity() core.Identity { + cfg, err := f.Config() + if err != nil || cfg.UserOpenId == "" { + return core.AsBot + } + stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId) + if stored == nil { + return core.AsBot + } + if auth.TokenStatus(stored) == "expired" { + return core.AsBot + } + return core.AsUser +} + +// CheckIdentity verifies the resolved identity is in the supported list. +// On success, sets f.ResolvedIdentity. On failure, returns an error +// tailored to whether the identity was explicit (--as) or auto-detected. +func (f *Factory) CheckIdentity(as core.Identity, supported []string) error { + for _, t := range supported { + if string(as) == t { + f.ResolvedIdentity = as + return nil + } + } + list := strings.Join(supported, ", ") + if f.IdentityAutoDetected { + return output.ErrValidation( + "resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s\nhint: use --as %s", + as, list, supported[0]) + } + return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list) +} + +// NewAPIClient creates an APIClient using the Factory's base Config (app credentials only). +// For user-mode calls where the correct user profile matters, use NewAPIClientWithConfig instead. +func (f *Factory) NewAPIClient() (*client.APIClient, error) { + cfg, err := f.Config() + if err != nil { + return nil, err + } + return f.NewAPIClientWithConfig(cfg) +} + +// NewAPIClientWithConfig creates an APIClient with an explicit config. +// Use this when the caller has already resolved the correct user profile +// (e.g. via AuthConfig for user-mode commands). +func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient, error) { + sdk, err := f.LarkClient() + if err != nil { + return nil, err + } + httpClient, err := f.HttpClient() + if err != nil { + return nil, err + } + errOut := io.Discard + if f.IOStreams != nil { + errOut = f.IOStreams.ErrOut + } + return &client.APIClient{Config: cfg, SDK: sdk, HTTP: httpClient, ErrOut: errOut}, nil +} diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go new file mode 100644 index 00000000..adfbb582 --- /dev/null +++ b/internal/cmdutil/factory_default.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "fmt" + "net/http" + "os" + "sync" + "time" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "golang.org/x/term" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/registry" +) + +// NewDefault creates a production Factory with cached closures. +func NewDefault() *Factory { + f := &Factory{ + Keychain: keychain.Default(), + } + f.IOStreams = &IOStreams{ + In: os.Stdin, + Out: os.Stdout, + ErrOut: os.Stderr, + IsTerminal: term.IsTerminal(int(os.Stdin.Fd())), + } + f.Config = cachedConfigFunc(f) + f.AuthConfig = cachedAuthConfigFunc(f) + f.HttpClient = cachedHttpClientFunc() + f.LarkClient = cachedLarkClientFunc(f) + return f +} + +func cachedConfigFunc(f *Factory) func() (*core.CliConfig, error) { + return sync.OnceValues(func() (*core.CliConfig, error) { + cfg, err := core.RequireConfig(f.Keychain) + if err != nil { + return cfg, err + } + registry.InitWithBrand(cfg.Brand) + return cfg, nil + }) +} + +func cachedAuthConfigFunc(f *Factory) func() (*core.CliConfig, error) { + return sync.OnceValues(func() (*core.CliConfig, error) { + return core.RequireAuth(f.Keychain) + }) +} + +// safeRedirectPolicy prevents credential headers from being forwarded +// when a response redirects to a different host (e.g. Lark API 302 → CDN). +// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host +// redirects; other headers like X-Cli-* pass through. +func safeRedirectPolicy(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("too many redirects") + } + if len(via) > 0 && req.URL.Host != via[0].URL.Host { + req.Header.Del("Authorization") + req.Header.Del("X-Lark-MCP-UAT") + req.Header.Del("X-Lark-MCP-TAT") + } + return nil +} + +func cachedHttpClientFunc() func() (*http.Client, error) { + return sync.OnceValues(func() (*http.Client, error) { + var transport = http.DefaultTransport + transport = &RetryTransport{Base: transport} + transport = &SecurityHeaderTransport{Base: transport} + + transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor + client := &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + CheckRedirect: safeRedirectPolicy, + } + return client, nil + }) +} + +func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) { + return sync.OnceValues(func() (*lark.Client, error) { + cfg, err := f.Config() + if err != nil { + return nil, err + } + opts := []lark.ClientOptionFunc{ + lark.WithLogLevel(larkcore.LogLevelError), + lark.WithHeaders(BaseSecurityHeaders()), + } + // Build SDK transport chain + var sdkTransport = http.DefaultTransport + sdkTransport = &UserAgentTransport{Base: sdkTransport} + sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport} + opts = append(opts, lark.WithHttpClient(&http.Client{ + Transport: sdkTransport, + CheckRedirect: safeRedirectPolicy, + })) + ep := core.ResolveEndpoints(cfg.Brand) + opts = append(opts, lark.WithOpenBaseUrl(ep.Open)) + client := lark.NewClient(cfg.AppID, cfg.AppSecret, opts...) + return client, nil + }) +} diff --git a/internal/cmdutil/factory_http_test.go b/internal/cmdutil/factory_http_test.go new file mode 100644 index 00000000..c27e9e69 --- /dev/null +++ b/internal/cmdutil/factory_http_test.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "testing" +) + +func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) { + fn := cachedHttpClientFunc() + + c1, err := fn() + if err != nil { + t.Fatalf("first call: %v", err) + } + if c1 == nil { + t.Fatal("first call returned nil") + } + + c2, err := fn() + if err != nil { + t.Fatalf("second call: %v", err) + } + if c1 != c2 { + t.Error("expected same *http.Client instance on second call (cache hit)") + } +} + +func TestCachedHttpClientFunc_HasTimeout(t *testing.T) { + fn := cachedHttpClientFunc() + c, _ := fn() + if c.Timeout == 0 { + t.Error("expected non-zero timeout") + } +} + +func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) { + fn := cachedHttpClientFunc() + c, _ := fn() + if c.CheckRedirect == nil { + t.Error("expected CheckRedirect to be set (safeRedirectPolicy)") + } +} diff --git a/internal/cmdutil/factory_test.go b/internal/cmdutil/factory_test.go new file mode 100644 index 00000000..9912bbce --- /dev/null +++ b/internal/cmdutil/factory_test.go @@ -0,0 +1,282 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/core" +) + +// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing. +func newCmdWithAsFlag(asValue string, changed bool) *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("as", "auto", "identity") + if changed { + _ = cmd.Flags().Set("as", asValue) + } + return cmd +} + +// --- ResolveAs tests --- + +func TestResolveAs_ExplicitAs(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := newCmdWithAsFlag("bot", true) + + got := f.ResolveAs(cmd, core.AsBot) + if got != core.AsBot { + t.Errorf("want bot, got %s", got) + } + if f.IdentityAutoDetected { + t.Error("IdentityAutoDetected should be false for explicit --as") + } + if f.ResolvedIdentity != core.AsBot { + t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity) + } +} + +func TestResolveAs_ExplicitAsUser(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := newCmdWithAsFlag("user", true) + + got := f.ResolveAs(cmd, core.AsUser) + if got != core.AsUser { + t.Errorf("want user, got %s", got) + } + if f.ResolvedIdentity != core.AsUser { + t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity) + } +} + +func TestResolveAs_ExplicitAuto_FallsToAutoDetect(t *testing.T) { + // --as auto explicitly: should fall through to auto-detect + // Config has no UserOpenId → auto-detect returns bot + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := newCmdWithAsFlag("auto", true) + + got := f.ResolveAs(cmd, "auto") + if got != core.AsBot { + t.Errorf("want bot (auto-detect, no login), got %s", got) + } + if !f.IdentityAutoDetected { + t.Error("IdentityAutoDetected should be true for auto-detect path") + } +} + +func TestResolveAs_DefaultAs_FromConfig(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{ + AppID: "a", AppSecret: "s", + DefaultAs: "bot", + }) + cmd := newCmdWithAsFlag("auto", false) // --as not changed + + got := f.ResolveAs(cmd, "auto") + if got != core.AsBot { + t.Errorf("want bot (from default-as config), got %s", got) + } + if f.IdentityAutoDetected { + t.Error("IdentityAutoDetected should be false for default-as path") + } +} + +func TestResolveAs_DefaultAs_FromEnv(t *testing.T) { + os.Setenv("LARKSUITE_CLI_DEFAULT_AS", "user") + defer os.Unsetenv("LARKSUITE_CLI_DEFAULT_AS") + + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + cmd := newCmdWithAsFlag("auto", false) + + got := f.ResolveAs(cmd, "auto") + if got != core.AsUser { + t.Errorf("want user (from env), got %s", got) + } +} + +func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) { + // default-as = "auto" should fall through to auto-detect + f, _, _, _ := TestFactory(t, &core.CliConfig{ + AppID: "a", AppSecret: "s", + DefaultAs: "auto", + }) + cmd := newCmdWithAsFlag("auto", false) + + got := f.ResolveAs(cmd, "auto") + // No UserOpenId → auto-detect returns bot + if got != core.AsBot { + t.Errorf("want bot (auto-detect), got %s", got) + } + if !f.IdentityAutoDetected { + t.Error("IdentityAutoDetected should be true") + } +} + +func TestResolveAs_NilCmd_AutoDetect(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + + got := f.ResolveAs(nil, "auto") + if got != core.AsBot { + t.Errorf("want bot, got %s", got) + } +} + +// --- CheckIdentity tests --- + +func TestCheckIdentity_Supported(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + + err := f.CheckIdentity(core.AsBot, []string{"bot", "user"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.ResolvedIdentity != core.AsBot { + t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity) + } +} + +func TestCheckIdentity_Supported_UserOnly(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + + err := f.CheckIdentity(core.AsUser, []string{"user"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.ResolvedIdentity != core.AsUser { + t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity) + } +} + +func TestCheckIdentity_Unsupported_Explicit(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f.IdentityAutoDetected = false // explicit --as + + err := f.CheckIdentity(core.AsUser, []string{"bot"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--as user is not supported") { + t.Errorf("unexpected error message: %v", err) + } + if !strings.Contains(err.Error(), "bot") { + t.Errorf("error should mention supported identity: %v", err) + } +} + +func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + f.IdentityAutoDetected = true + + err := f.CheckIdentity(core.AsUser, []string{"bot"}) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "resolved identity") { + t.Errorf("expected 'resolved identity' in error, got: %v", err) + } + if !strings.Contains(err.Error(), "hint: use --as bot") { + t.Errorf("expected hint in error, got: %v", err) + } +} + +// --- ResolveConfig tests --- + +func TestResolveConfig_Bot(t *testing.T) { + cfg := &core.CliConfig{AppID: "a", AppSecret: "s"} + f, _, _, _ := TestFactory(t, cfg) + + got, err := f.ResolveConfig(core.AsBot) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.AppID != "a" { + t.Errorf("want AppID a, got %s", got.AppID) + } +} + +func TestResolveConfig_User(t *testing.T) { + cfg := &core.CliConfig{AppID: "a", AppSecret: "s"} + f, _, _, _ := TestFactory(t, cfg) + + got, err := f.ResolveConfig(core.AsUser) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.AppID != "a" { + t.Errorf("want AppID a, got %s", got.AppID) + } +} + +// --- autoDetectIdentity tests --- + +func TestAutoDetectIdentity_NoUserOpenId(t *testing.T) { + f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"}) + got := f.autoDetectIdentity() + if got != core.AsBot { + t.Errorf("want bot (no UserOpenId), got %s", got) + } +} + +func TestAutoDetectIdentity_ConfigError(t *testing.T) { + f := &Factory{ + Config: func() (*core.CliConfig, error) { + return nil, os.ErrNotExist + }, + } + got := f.autoDetectIdentity() + if got != core.AsBot { + t.Errorf("want bot (config error), got %s", got) + } +} + +// --- NewAPIClient / NewAPIClientWithConfig tests --- + +func TestNewAPIClient(t *testing.T) { + cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark} + f, _, _, _ := TestFactory(t, cfg) + + ac, err := f.NewAPIClient() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ac.Config.AppID != "a" { + t.Errorf("want AppID a, got %s", ac.Config.AppID) + } +} + +func TestNewAPIClientWithConfig(t *testing.T) { + cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark} + f, _, _, _ := TestFactory(t, cfg) + + ac, err := f.NewAPIClientWithConfig(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ac.Config.AppID != "a" { + t.Errorf("want AppID a, got %s", ac.Config.AppID) + } + if ac.SDK == nil { + t.Error("SDK should not be nil") + } + if ac.HTTP == nil { + t.Error("HTTP should not be nil") + } +} + +func TestNewAPIClientWithConfig_NilIOStreams(t *testing.T) { + cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark} + f, _, _, _ := TestFactory(t, cfg) + f.IOStreams = nil + + ac, err := f.NewAPIClientWithConfig(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ac == nil { + t.Fatal("expected non-nil APIClient") + } +} diff --git a/internal/cmdutil/identity.go b/internal/cmdutil/identity.go new file mode 100644 index 00000000..040f9e62 --- /dev/null +++ b/internal/cmdutil/identity.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "fmt" + "io" + + "github.com/larksuite/cli/internal/core" +) + +// AccessTokensToIdentities converts from_meta accessTokens (e.g. ["tenant", "user"]) +// to CLI identity names (e.g. ["bot", "user"]). +func AccessTokensToIdentities(tokens []interface{}) []string { + var identities []string + for _, t := range tokens { + if ts, ok := t.(string); ok { + if ts == "tenant" { + identities = append(identities, "bot") + } else { + identities = append(identities, ts) + } + } + } + return identities +} + +// PrintIdentity outputs the current identity to stderr so callers (including AI agents) +// can see which identity is being used for the API call. +func PrintIdentity(w io.Writer, as core.Identity, config *core.CliConfig, autoDetected bool) { + if as.IsBot() { + if autoDetected { + fmt.Fprintln(w, "[identity: bot (auto — not logged in; `auth login` for user identity)]") + } else { + fmt.Fprintln(w, "[identity: bot]") + } + } else if config != nil && config.UserOpenId != "" { + fmt.Fprintf(w, "[identity: user (%s)]\n", config.UserOpenId) + } else { + fmt.Fprintln(w, "[identity: user]") + } +} diff --git a/internal/cmdutil/identity_test.go b/internal/cmdutil/identity_test.go new file mode 100644 index 00000000..d65e2b08 --- /dev/null +++ b/internal/cmdutil/identity_test.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "bytes" + "strings" + "testing" + + "github.com/larksuite/cli/internal/core" +) + +func TestAccessTokensToIdentities(t *testing.T) { + tests := []struct { + name string + tokens []interface{} + want []string + }{ + { + name: "tenant becomes bot", + tokens: []interface{}{"tenant"}, + want: []string{"bot"}, + }, + { + name: "user stays user", + tokens: []interface{}{"user"}, + want: []string{"user"}, + }, + { + name: "tenant and user", + tokens: []interface{}{"tenant", "user"}, + want: []string{"bot", "user"}, + }, + { + name: "empty list", + tokens: []interface{}{}, + want: nil, + }, + { + name: "non-string values skipped", + tokens: []interface{}{"tenant", 42, "user"}, + want: []string{"bot", "user"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := AccessTokensToIdentities(tt.tokens) + if len(got) != len(tt.want) { + t.Fatalf("len: want %d, got %d (%v)", len(tt.want), len(got), got) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("[%d] want %s, got %s", i, tt.want[i], got[i]) + } + } + }) + } +} + +func TestPrintIdentity_BotExplicit(t *testing.T) { + var buf bytes.Buffer + PrintIdentity(&buf, core.AsBot, nil, false) + if !strings.Contains(buf.String(), "[identity: bot]") { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestPrintIdentity_BotAutoDetected(t *testing.T) { + var buf bytes.Buffer + PrintIdentity(&buf, core.AsBot, nil, true) + if !strings.Contains(buf.String(), "auto") { + t.Errorf("expected auto hint, got: %s", buf.String()) + } +} + +func TestPrintIdentity_UserWithOpenId(t *testing.T) { + var buf bytes.Buffer + cfg := &core.CliConfig{UserOpenId: "ou_abc123"} + PrintIdentity(&buf, core.AsUser, cfg, false) + if !strings.Contains(buf.String(), "ou_abc123") { + t.Errorf("expected UserOpenId in output, got: %s", buf.String()) + } +} + +func TestPrintIdentity_UserWithoutOpenId(t *testing.T) { + var buf bytes.Buffer + PrintIdentity(&buf, core.AsUser, &core.CliConfig{}, false) + if !strings.Contains(buf.String(), "[identity: user]") { + t.Errorf("unexpected output: %s", buf.String()) + } +} + +func TestPrintIdentity_UserNilConfig(t *testing.T) { + var buf bytes.Buffer + PrintIdentity(&buf, core.AsUser, nil, false) + if !strings.Contains(buf.String(), "[identity: user]") { + t.Errorf("unexpected output: %s", buf.String()) + } +} diff --git a/internal/cmdutil/iostreams.go b/internal/cmdutil/iostreams.go new file mode 100644 index 00000000..76068e05 --- /dev/null +++ b/internal/cmdutil/iostreams.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import "io" + +// IOStreams provides the standard input/output/error streams. +// Commands should use these instead of os.Stdin/Stdout/Stderr +// to enable testing and output capture. +type IOStreams struct { + In io.Reader + Out io.Writer + ErrOut io.Writer + IsTerminal bool +} diff --git a/internal/cmdutil/json.go b/internal/cmdutil/json.go new file mode 100644 index 00000000..6a162c4e --- /dev/null +++ b/internal/cmdutil/json.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "encoding/json" + + "github.com/larksuite/cli/internal/output" +) + +// ParseOptionalBody parses --data JSON for methods that accept a request body. +// Returns (nil, nil) if the method has no body or data is empty. +func ParseOptionalBody(httpMethod, data string) (interface{}, error) { + switch httpMethod { + case "POST", "PUT", "PATCH", "DELETE": + default: + return nil, nil + } + if data == "" { + return nil, nil + } + var body interface{} + if err := json.Unmarshal([]byte(data), &body); err != nil { + return nil, output.ErrValidation("--data invalid JSON format") + } + return body, nil +} + +// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty. +func ParseJSONMap(input, label string) (map[string]any, error) { + if input == "" { + return map[string]any{}, nil + } + var result map[string]any + if err := json.Unmarshal([]byte(input), &result); err != nil { + return nil, output.ErrValidation("%s invalid format, expected JSON object", label) + } + return result, nil +} diff --git a/internal/cmdutil/json_test.go b/internal/cmdutil/json_test.go new file mode 100644 index 00000000..e88218a1 --- /dev/null +++ b/internal/cmdutil/json_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import "testing" + +func TestParseOptionalBody(t *testing.T) { + tests := []struct { + name string + method string + data string + wantNil bool + wantErr bool + }{ + {"GET ignored", "GET", `{"a":1}`, true, false}, + {"POST empty data", "POST", "", true, false}, + {"POST valid", "POST", `{"key":"val"}`, false, false}, + {"PUT valid", "PUT", `[1,2,3]`, false, false}, + {"PATCH valid", "PATCH", `"hello"`, false, false}, + {"DELETE valid", "DELETE", `{"id":"1"}`, false, false}, + {"POST invalid json", "POST", `{bad}`, true, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseOptionalBody(tt.method, tt.data) + if (err != nil) != tt.wantErr { + t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantNil && got != nil { + t.Errorf("ParseOptionalBody() = %v, want nil", got) + } + if !tt.wantNil && !tt.wantErr && got == nil { + t.Error("ParseOptionalBody() = nil, want non-nil") + } + }) + } +} + +func TestParseJSONMap(t *testing.T) { + tests := []struct { + name string + input string + label string + wantLen int + wantErr bool + }{ + {"empty input", "", "--params", 0, false}, + {"valid json", `{"a":"1","b":"2"}`, "--params", 2, false}, + {"invalid json", `{bad}`, "--params", 0, true}, + {"json array", `[1,2]`, "--data", 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseJSONMap(tt.input, tt.label) + if (err != nil) != tt.wantErr { + t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && len(got) != tt.wantLen { + t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen) + } + }) + } +} diff --git a/internal/cmdutil/retry_transport_test.go b/internal/cmdutil/retry_transport_test.go new file mode 100644 index 00000000..a10782ac --- /dev/null +++ b/internal/cmdutil/retry_transport_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "io" + "net/http" + "strings" + "testing" + "time" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestRetryTransport_NoRetry(t *testing.T) { + calls := 0 + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil + }) + rt := &RetryTransport{Base: base, MaxRetries: 0} + req, _ := http.NewRequest("GET", "http://example.com/test", nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + if calls != 1 { + t.Errorf("expected 1 call, got %d", calls) + } +} + +func TestRetryTransport_RetryOn500(t *testing.T) { + calls := 0 + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + if calls < 3 { + return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil + }) + rt := &RetryTransport{Base: base, MaxRetries: 3, Delay: 1 * time.Millisecond} + req, _ := http.NewRequest("GET", "http://example.com/test", nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("expected 200 after retries, got %d", resp.StatusCode) + } + if calls != 3 { + t.Errorf("expected 3 calls, got %d", calls) + } +} + +func TestRetryTransport_DefaultNoRetry(t *testing.T) { + calls := 0 + base := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil + }) + rt := &RetryTransport{Base: base} // default MaxRetries=0 + req, _ := http.NewRequest("GET", "http://example.com/test", nil) + resp, err := rt.RoundTrip(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 500 { + t.Errorf("expected 500 with no retries, got %d", resp.StatusCode) + } + if calls != 1 { + t.Errorf("expected 1 call with default config, got %d", calls) + } +} diff --git a/internal/cmdutil/secheader.go b/internal/cmdutil/secheader.go new file mode 100644 index 00000000..15745264 --- /dev/null +++ b/internal/cmdutil/secheader.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "context" + "net/http" + + "github.com/larksuite/cli/internal/build" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +const ( + HeaderSource = "X-Cli-Source" + HeaderVersion = "X-Cli-Version" + HeaderShortcut = "X-Cli-Shortcut" + HeaderExecutionId = "X-Cli-Execution-Id" + + SourceValue = "lark-cli" + + HeaderUserAgent = "User-Agent" +) + +// UserAgentValue returns the User-Agent value: "lark-cli/{version}". +func UserAgentValue() string { + return SourceValue + "/" + build.Version +} + +// BaseSecurityHeaders returns headers that every request must carry. +func BaseSecurityHeaders() http.Header { + h := make(http.Header) + h.Set(HeaderSource, SourceValue) + h.Set(HeaderVersion, build.Version) + h.Set(HeaderUserAgent, UserAgentValue()) + return h +} + +// ── Context utilities ── + +type ctxKey string + +const ( + ctxShortcutName ctxKey = "lark:shortcut-name" + ctxExecutionId ctxKey = "lark:execution-id" +) + +// ContextWithShortcut injects shortcut name and execution ID into the context. +func ContextWithShortcut(ctx context.Context, name, executionId string) context.Context { + ctx = context.WithValue(ctx, ctxShortcutName, name) + ctx = context.WithValue(ctx, ctxExecutionId, executionId) + return ctx +} + +// ShortcutNameFromContext extracts the shortcut name from the context. +func ShortcutNameFromContext(ctx context.Context) (string, bool) { + v, ok := ctx.Value(ctxShortcutName).(string) + return v, ok && v != "" +} + +// ExecutionIdFromContext extracts the execution ID from the context. +func ExecutionIdFromContext(ctx context.Context) (string, bool) { + v, ok := ctx.Value(ctxExecutionId).(string) + return v, ok && v != "" +} + +// ShortcutHeaderOpts extracts Shortcut info from the context and returns a +// RequestOptionFunc that injects the corresponding headers into SDK requests. +// Returns nil if the context has no Shortcut info. +func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc { + name, ok := ShortcutNameFromContext(ctx) + if !ok { + return nil + } + h := make(http.Header) + h.Set(HeaderShortcut, name) + if eid, ok := ExecutionIdFromContext(ctx); ok { + h.Set(HeaderExecutionId, eid) + } + return larkcore.WithHeaders(h) +} diff --git a/internal/cmdutil/testing.go b/internal/cmdutil/testing.go new file mode 100644 index 00000000..e32b17f0 --- /dev/null +++ b/internal/cmdutil/testing.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "bytes" + "net/http" + "testing" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +// noopKeychain is a no-op KeychainAccess for tests that don't need keychain. +type noopKeychain struct{} + +func (n *noopKeychain) Get(service, account string) (string, error) { return "", nil } +func (n *noopKeychain) Set(service, account, value string) error { return nil } +func (n *noopKeychain) Remove(service, account string) error { return nil } + +// TestFactory creates a Factory for testing. +// Returns (factory, stdout buffer, stderr buffer, http mock registry). +func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + + reg := &httpmock.Registry{} + t.Cleanup(func() { reg.Verify(t) }) + + stdoutBuf := &bytes.Buffer{} + stderrBuf := &bytes.Buffer{} + + mockClient := httpmock.NewClient(reg) + // SDK mock client wraps the mock transport with UserAgentTransport + // so that User-Agent overrides the SDK default (oapi-sdk-go/v3.x.x). + sdkMockClient := &http.Client{ + Transport: &UserAgentTransport{Base: reg}, + } + + // Build a test LarkClient using the config + var testLarkClient *lark.Client + if config != nil && config.AppID != "" { + opts := []lark.ClientOptionFunc{ + lark.WithLogLevel(larkcore.LogLevelError), + lark.WithHttpClient(sdkMockClient), + lark.WithHeaders(BaseSecurityHeaders()), + } + if config.Brand != "" { + opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(config.Brand))) + } + testLarkClient = lark.NewClient(config.AppID, config.AppSecret, opts...) + } + + f := &Factory{ + Config: func() (*core.CliConfig, error) { return config, nil }, + AuthConfig: func() (*core.CliConfig, error) { return config, nil }, + HttpClient: func() (*http.Client, error) { return mockClient, nil }, + LarkClient: func() (*lark.Client, error) { return testLarkClient, nil }, + IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf}, + Keychain: &noopKeychain{}, + } + return f, stdoutBuf, stderrBuf, reg +} diff --git a/internal/cmdutil/testing_test.go b/internal/cmdutil/testing_test.go new file mode 100644 index 00000000..e12d5370 --- /dev/null +++ b/internal/cmdutil/testing_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +func TestTestFactory_ReplacesGlobals(t *testing.T) { + config := &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", + Brand: core.BrandFeishu, + } + + f, stdout, stderr, reg := TestFactory(t, config) + + // Factory should return our config + got, err := f.Config() + if err != nil { + t.Fatalf("Config() error: %v", err) + } + if got.AppID != "test-app" { + t.Errorf("want AppID test-app, got %s", got.AppID) + } + + // IOStreams.Out/ErrOut should be our buffers + output.PrintJson(f.IOStreams.Out, map[string]string{"key": "value"}) + if !strings.Contains(stdout.String(), `"key"`) { + t.Error("output.PrintJson did not write to test stdout") + } + + output.PrintError(f.IOStreams.ErrOut, "test error") + if !strings.Contains(stderr.String(), "test error") { + t.Error("output.PrintError did not write to test stderr") + } + + // Register a stub so Verify passes + reg.Register(&httpmock.Stub{ + URL: "/test", + Body: "ok", + }) + // Use the stub via Factory HttpClient + httpClient, err := f.HttpClient() + if err != nil { + t.Fatalf("HttpClient() error: %v", err) + } + baseURL := core.ResolveOpenBaseURL(core.BrandFeishu) + req, _ := http.NewRequest("GET", baseURL+"/test", nil) + resp, err := httpClient.Do(req) + if err != nil { + t.Fatalf("HttpClient request error: %v", err) + } + resp.Body.Close() +} diff --git a/internal/cmdutil/theme.go b/internal/cmdutil/theme.go new file mode 100644 index 00000000..276126d0 --- /dev/null +++ b/internal/cmdutil/theme.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// ThemeFeishu returns a huh theme with Feishu brand colors. +func ThemeFeishu() *huh.Theme { + t := huh.ThemeBase() + + var ( + blue = lipgloss.Color("#1456F0") // 标题、边框 + teal = lipgloss.Color("#33D6C0") // 选择器、光标、输入提示 + cyan = lipgloss.Color("#3EC3C0") // 选中项 + orange = lipgloss.Color("#FF811A") // 按钮高亮 + magenta = lipgloss.Color("#CC398C") // 错误 + text = lipgloss.AdaptiveColor{Light: "#1F2329", Dark: "#E8E8E8"} + subtext = lipgloss.AdaptiveColor{Light: "#8F959E", Dark: "#8F959E"} + btnBg = lipgloss.AdaptiveColor{Light: "#EEF3FF", Dark: "#2B3A5C"} + ) + + t.Focused.Base = t.Focused.Base.BorderForeground(blue) + t.Focused.Card = t.Focused.Base + t.Focused.Title = t.Focused.Title.Foreground(blue).Bold(true) + t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(blue).Bold(true) + t.Focused.Description = t.Focused.Description.Foreground(subtext) + t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(magenta) + t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(magenta) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(teal) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(teal) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(teal) + t.Focused.Option = t.Focused.Option.Foreground(text) + t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(teal) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(cyan) + t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(cyan).SetString("✓ ") + t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(text) + t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.Foreground(subtext).SetString("• ") + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("#FFFFFF")).Background(orange).Bold(true) + t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(text).Background(btnBg) + + t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(teal) + t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtext) + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(teal) + + t.Blurred = t.Focused + t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.Card = t.Blurred.Base + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + t.Group.Title = t.Focused.Title + t.Group.Description = t.Focused.Description + return t +} diff --git a/internal/cmdutil/tips.go b/internal/cmdutil/tips.go new file mode 100644 index 00000000..2cf944d5 --- /dev/null +++ b/internal/cmdutil/tips.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "encoding/json" + + "github.com/spf13/cobra" +) + +const tipsAnnotationKey = "tips" + +// SetTips sets the tips for a command (stored as JSON in Annotations). +func SetTips(cmd *cobra.Command, tips []string) { + if len(tips) == 0 { + return + } + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + data, _ := json.Marshal(tips) + cmd.Annotations[tipsAnnotationKey] = string(data) +} + +// AddTips appends tips to a command (merges with existing). +func AddTips(cmd *cobra.Command, tips ...string) { + existing := GetTips(cmd) + SetTips(cmd, append(existing, tips...)) +} + +// GetTips retrieves the tips from a command's annotations. +func GetTips(cmd *cobra.Command) []string { + if cmd.Annotations == nil { + return nil + } + raw, ok := cmd.Annotations[tipsAnnotationKey] + if !ok { + return nil + } + var tips []string + err := json.Unmarshal([]byte(raw), &tips) + if err != nil { + return nil + } + return tips +} diff --git a/internal/cmdutil/tips_test.go b/internal/cmdutil/tips_test.go new file mode 100644 index 00000000..1ee88545 --- /dev/null +++ b/internal/cmdutil/tips_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestSetTipsAndGetTips(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + tips := []string{"tip one", "tip two"} + SetTips(cmd, tips) + + got := GetTips(cmd) + if len(got) != 2 || got[0] != "tip one" || got[1] != "tip two" { + t.Fatalf("expected %v, got %v", tips, got) + } +} + +func TestSetTipsEmpty(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + SetTips(cmd, nil) + + if cmd.Annotations != nil { + t.Fatal("expected nil annotations for empty tips") + } +} + +func TestGetTipsNoAnnotations(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + got := GetTips(cmd) + if got != nil { + t.Fatalf("expected nil, got %v", got) + } +} + +func TestAddTips(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + SetTips(cmd, []string{"first"}) + AddTips(cmd, "second", "third") + + got := GetTips(cmd) + if len(got) != 3 || got[0] != "first" || got[1] != "second" || got[2] != "third" { + t.Fatalf("expected [first second third], got %v", got) + } +} + +func TestAddTipsToEmpty(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + AddTips(cmd, "only") + + got := GetTips(cmd) + if len(got) != 1 || got[0] != "only" { + t.Fatalf("expected [only], got %v", got) + } +} diff --git a/internal/cmdutil/transport.go b/internal/cmdutil/transport.go new file mode 100644 index 00000000..bbdc0d3e --- /dev/null +++ b/internal/cmdutil/transport.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "net/http" + "time" +) + +// RetryTransport is an http.RoundTripper that retries on 5xx responses +// and network errors. MaxRetries defaults to 0 (no retries). +type RetryTransport struct { + Base http.RoundTripper + MaxRetries int + Delay time.Duration // base delay for exponential backoff; defaults to 500ms +} + +func (t *RetryTransport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} + +func (t *RetryTransport) delay() time.Duration { + if t.Delay > 0 { + return t.Delay + } + return 500 * time.Millisecond +} + +// RoundTrip implements http.RoundTripper. +func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.base().RoundTrip(req) + if t.MaxRetries <= 0 { + return resp, err + } + + for attempt := 0; attempt < t.MaxRetries; attempt++ { + if err == nil && resp.StatusCode < 500 { + return resp, nil + } + // Clone request for retry + cloned := req.Clone(req.Context()) + if req.Body != nil && req.GetBody != nil { + cloned.Body, _ = req.GetBody() + } + delay := t.delay() * (1 << uint(attempt)) + time.Sleep(delay) + resp, err = t.base().RoundTrip(cloned) + } + return resp, err +} + +// UserAgentTransport is an http.RoundTripper that sets the User-Agent header. +// Used in the SDK transport chain to override the SDK's default User-Agent. +type UserAgentTransport struct { + Base http.RoundTripper +} + +func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set(HeaderUserAgent, UserAgentValue()) + if t.Base != nil { + return t.Base.RoundTrip(req) + } + return http.DefaultTransport.RoundTrip(req) +} + +// SecurityHeaderTransport is an http.RoundTripper that injects CLI security +// headers into every request. Shortcut headers are read from the request context. +type SecurityHeaderTransport struct { + Base http.RoundTripper +} + +func (t *SecurityHeaderTransport) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +} + +// RoundTrip implements http.RoundTripper. +func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + for k, vs := range BaseSecurityHeaders() { + for _, v := range vs { + req.Header.Set(k, v) + } + } + // Shortcut headers are propagated via context (see section 5.6 of the design doc). + if name, ok := ShortcutNameFromContext(req.Context()); ok { + req.Header.Set(HeaderShortcut, name) + } + if eid, ok := ExecutionIdFromContext(req.Context()); ok { + req.Header.Set(HeaderExecutionId, eid) + } + return t.base().RoundTrip(req) +} diff --git a/internal/core/config.go b/internal/core/config.go new file mode 100644 index 00000000..ced1e27b --- /dev/null +++ b/internal/core/config.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/validate" +) + +// Identity represents the caller identity for API requests. +type Identity string + +const ( + AsUser Identity = "user" + AsBot Identity = "bot" +) + +// IsBot returns true if the identity is bot. +func (id Identity) IsBot() bool { return id == AsBot } + +// AppUser is a logged-in user record stored in config. +type AppUser struct { + UserOpenId string `json:"userOpenId"` + UserName string `json:"userName"` +} + +// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved). +type AppConfig struct { + AppId string `json:"appId"` + AppSecret SecretInput `json:"appSecret"` + Brand LarkBrand `json:"brand"` + Lang string `json:"lang,omitempty"` + DefaultAs string `json:"defaultAs,omitempty"` // "user" | "bot" | "auto" + Users []AppUser `json:"users"` +} + +// MultiAppConfig is the multi-app config file format. +type MultiAppConfig struct { + Apps []AppConfig `json:"apps"` +} + +// CliConfig is the resolved single-app config used by downstream code. +type CliConfig struct { + AppID string + AppSecret string + Brand LarkBrand + DefaultAs string // "user" | "bot" | "auto" | "" (from config file) + UserOpenId string + UserName string +} + +// GetConfigDir returns the config directory path. +// If the home directory cannot be determined, it falls back to a relative path +// and prints a warning to stderr. +func GetConfigDir() string { + if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" { + return dir + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) + } + return filepath.Join(home, ".lark-cli") +} + +// GetConfigPath returns the config file path. +func GetConfigPath() string { + return filepath.Join(GetConfigDir(), "config.json") +} + +// LoadMultiAppConfig loads multi-app config from disk. +func LoadMultiAppConfig() (*MultiAppConfig, error) { + data, err := os.ReadFile(GetConfigPath()) + if err != nil { + return nil, err + } + + var multi MultiAppConfig + if err := json.Unmarshal(data, &multi); err != nil { + return nil, fmt.Errorf("invalid config format: %w", err) + } + if len(multi.Apps) == 0 { + return nil, fmt.Errorf("invalid config format: no apps") + } + return &multi, nil +} + +// SaveMultiAppConfig saves config to disk. +func SaveMultiAppConfig(config *MultiAppConfig) error { + dir := GetConfigDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + return validate.AtomicWrite(GetConfigPath(), append(data, '\n'), 0600) +} + +// RequireConfig loads the single-app config. Takes Apps[0] directly. +func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) { + raw, err := LoadMultiAppConfig() + if err != nil || raw == nil || len(raw.Apps) == 0 { + return nil, &ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."} + } + app := raw.Apps[0] + secret, err := ResolveSecretInput(app.AppSecret, kc) + if err != nil { + return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()} + } + cfg := &CliConfig{ + AppID: app.AppId, + AppSecret: secret, + Brand: app.Brand, + DefaultAs: app.DefaultAs, + } + if len(app.Users) > 0 { + cfg.UserOpenId = app.Users[0].UserOpenId + cfg.UserName = app.Users[0].UserName + } + return cfg, nil +} + +// RequireAuth loads config and ensures a user is logged in. +func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) { + cfg, err := RequireConfig(kc) + if err != nil { + return nil, err + } + if cfg.UserOpenId == "" { + return nil, &ConfigError{Code: 3, Type: "auth", Message: "not logged in", Hint: "run `lark-cli auth login` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login."} + } + return cfg, nil +} diff --git a/internal/core/config_test.go b/internal/core/config_test.go new file mode 100644 index 00000000..1c9ac449 --- /dev/null +++ b/internal/core/config_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "encoding/json" + "testing" +) + +func TestAppConfig_LangSerialization(t *testing.T) { + app := AppConfig{ + AppId: "cli_test", AppSecret: PlainSecret("secret"), + Brand: BrandFeishu, Lang: "en", Users: []AppUser{}, + } + data, err := json.Marshal(app) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got AppConfig + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.Lang != "en" { + t.Errorf("Lang = %q, want %q", got.Lang, "en") + } +} + +func TestAppConfig_LangOmitEmpty(t *testing.T) { + app := AppConfig{ + AppId: "cli_test", AppSecret: PlainSecret("secret"), + Brand: BrandFeishu, Users: []AppUser{}, + } + data, err := json.Marshal(app) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Lang should be omitted when empty + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal raw: %v", err) + } + if _, exists := raw["lang"]; exists { + t.Error("expected lang to be omitted when empty") + } +} + +func TestMultiAppConfig_RoundTrip(t *testing.T) { + config := &MultiAppConfig{ + Apps: []AppConfig{{ + AppId: "cli_test", AppSecret: PlainSecret("s"), + Brand: BrandLark, Lang: "zh", Users: []AppUser{}, + }}, + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var got MultiAppConfig + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got.Apps) != 1 { + t.Fatalf("expected 1 app, got %d", len(got.Apps)) + } + if got.Apps[0].Lang != "zh" { + t.Errorf("Lang = %q, want %q", got.Apps[0].Lang, "zh") + } + if got.Apps[0].Brand != BrandLark { + t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark) + } +} diff --git a/internal/core/errors.go b/internal/core/errors.go new file mode 100644 index 00000000..14d443a0 --- /dev/null +++ b/internal/core/errors.go @@ -0,0 +1,22 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import "fmt" + +// ConfigError is a structured error from config resolution. +// It carries enough information for main.go to convert it into an output.ExitError. +type ConfigError struct { + Code int // exit code: 2=validation, 3=auth + Type string // "config" or "auth" + Message string + Hint string +} + +func (e *ConfigError) Error() string { + if e.Hint != "" { + return fmt.Sprintf("%s\n %s", e.Message, e.Hint) + } + return e.Message +} diff --git a/internal/core/secret.go b/internal/core/secret.go new file mode 100644 index 00000000..a488e5dc --- /dev/null +++ b/internal/core/secret.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "encoding/json" + "fmt" +) + +// --------------------------------------------------------------------------- +// SecretRef — external secret reference +// --------------------------------------------------------------------------- + +// SecretRef references a secret stored externally. +type SecretRef struct { + Source string `json:"source"` // "file" | "keychain" + Provider string `json:"provider,omitempty"` // optional, reserved + ID string `json:"id"` // env var name / file path / command / keychain key +} + +// --------------------------------------------------------------------------- +// SecretInput — union type: plain string or SecretRef +// --------------------------------------------------------------------------- + +// SecretInput represents a secret value: either a plain string or a SecretRef object. +type SecretInput struct { + Plain string // non-empty for plain string values + Ref *SecretRef // non-nil for SecretRef values +} + +// PlainSecret creates a SecretInput from a plain string. +func PlainSecret(s string) SecretInput { + return SecretInput{Plain: s} +} + +// IsZero returns true if the SecretInput has no value. +func (s SecretInput) IsZero() bool { + return s.Plain == "" && s.Ref == nil +} + +// IsSecretRef returns true if this is a SecretRef object (env/file/keychain). +func (s SecretInput) IsSecretRef() bool { + return s.Ref != nil +} + +// IsPlain returns true if this is a plain text string (not a SecretRef). +func (s SecretInput) IsPlain() bool { + return s.Ref == nil +} + +// MarshalJSON serializes SecretInput: plain string → JSON string, SecretRef → JSON object. +func (s SecretInput) MarshalJSON() ([]byte, error) { + if s.Ref != nil { + return json.Marshal(s.Ref) + } + return json.Marshal(s.Plain) +} + +// UnmarshalJSON deserializes SecretInput from either a JSON string or a SecretRef object. +func (s *SecretInput) UnmarshalJSON(data []byte) error { + // Try string first + var plain string + if err := json.Unmarshal(data, &plain); err == nil { + s.Plain = plain + s.Ref = nil + return nil + } + // Try SecretRef object + var ref SecretRef + if err := json.Unmarshal(data, &ref); err == nil && isValidSource(ref.Source) && ref.ID != "" { + s.Ref = &ref + s.Plain = "" + return nil + } + return fmt.Errorf("appSecret must be a string or {source, id} object") +} + +// ValidSecretSources is the set of recognized SecretRef sources. +var ValidSecretSources = map[string]bool{ + "file": true, "keychain": true, +} + +func isValidSource(source string) bool { + return ValidSecretSources[source] +} diff --git a/internal/core/secret_resolve.go b/internal/core/secret_resolve.go new file mode 100644 index 00000000..6e7921d3 --- /dev/null +++ b/internal/core/secret_resolve.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import ( + "fmt" + "os" + "strings" + + "github.com/larksuite/cli/internal/keychain" +) + +const secretKeyPrefix = "appsecret:" + +func secretAccountKey(appId string) string { + return secretKeyPrefix + appId +} + +// ResolveSecretInput resolves a SecretInput to a plain string. +// SecretRef objects are resolved by source (file / keychain). +func ResolveSecretInput(s SecretInput, kc keychain.KeychainAccess) (string, error) { + if s.Ref == nil { + return s.Plain, nil + } + switch s.Ref.Source { + case "file": + data, err := os.ReadFile(s.Ref.ID) + if err != nil { + return "", fmt.Errorf("failed to read secret file %s: %w", s.Ref.ID, err) + } + return strings.TrimSpace(string(data)), nil + case "keychain": + return kc.Get(keychain.LarkCliService, s.Ref.ID) + default: + return "", fmt.Errorf("unknown secret source: %s", s.Ref.Source) + } +} + +// ForStorage determines how to store a secret in config.json. +// - SecretRef → preserved as-is +// - Plain text → stored in keychain, returns keychain SecretRef +// Returns error if keychain is unavailable (no silent plaintext fallback). +func ForStorage(appId string, input SecretInput, kc keychain.KeychainAccess) (SecretInput, error) { + if !input.IsPlain() { + return input, nil // SecretRef → keep as-is + } + key := secretAccountKey(appId) + if err := kc.Set(keychain.LarkCliService, key, input.Plain); err != nil { + return SecretInput{}, fmt.Errorf("keychain unavailable: %w\nhint: use file: reference in config to bypass keychain", err) + } + return SecretInput{Ref: &SecretRef{Source: "keychain", ID: key}}, nil +} + +// RemoveSecretStore cleans up keychain entries when an app is removed. +// Errors are intentionally ignored — cleanup is best-effort. +func RemoveSecretStore(input SecretInput, kc keychain.KeychainAccess) { + if input.IsSecretRef() && input.Ref.Source == "keychain" { + _ = kc.Remove(keychain.LarkCliService, input.Ref.ID) + } +} diff --git a/internal/core/types.go b/internal/core/types.go new file mode 100644 index 00000000..4c21c259 --- /dev/null +++ b/internal/core/types.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +// LarkBrand represents the Lark platform brand. +// "feishu" targets China-mainland, "lark" targets international. +// Any other string is treated as a custom base URL. +type LarkBrand string + +const ( + BrandFeishu LarkBrand = "feishu" + BrandLark LarkBrand = "lark" +) + +// Endpoints holds resolved endpoint URLs for different Lark services. +type Endpoints struct { + Open string // e.g. "https://open.feishu.cn" + Accounts string // e.g. "https://accounts.feishu.cn" + MCP string // e.g. "https://mcp.feishu.cn" +} + +// ResolveEndpoints resolves endpoint URLs based on brand. +func ResolveEndpoints(brand LarkBrand) Endpoints { + switch brand { + case BrandLark: + return Endpoints{ + Open: "https://open.larksuite.com", + Accounts: "https://accounts.larksuite.com", + MCP: "https://mcp.larksuite.com", + } + default: + return Endpoints{ + Open: "https://open.feishu.cn", + Accounts: "https://accounts.feishu.cn", + MCP: "https://mcp.feishu.cn", + } + } +} + +// ResolveOpenBaseURL returns the Open API base URL for the given brand. +func ResolveOpenBaseURL(brand LarkBrand) string { + return ResolveEndpoints(brand).Open +} diff --git a/internal/core/types_test.go b/internal/core/types_test.go new file mode 100644 index 00000000..839b5b55 --- /dev/null +++ b/internal/core/types_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +import "testing" + +func TestResolveEndpoints_Feishu(t *testing.T) { + ep := ResolveEndpoints(BrandFeishu) + if ep.Open != "https://open.feishu.cn" { + t.Errorf("Open = %q, want feishu.cn", ep.Open) + } + if ep.Accounts != "https://accounts.feishu.cn" { + t.Errorf("Accounts = %q, want feishu.cn", ep.Accounts) + } + if ep.MCP != "https://mcp.feishu.cn" { + t.Errorf("MCP = %q, want feishu.cn", ep.MCP) + } +} + +func TestResolveEndpoints_Lark(t *testing.T) { + ep := ResolveEndpoints(BrandLark) + if ep.Open != "https://open.larksuite.com" { + t.Errorf("Open = %q, want larksuite.com", ep.Open) + } + if ep.Accounts != "https://accounts.larksuite.com" { + t.Errorf("Accounts = %q, want larksuite.com", ep.Accounts) + } + if ep.MCP != "https://mcp.larksuite.com" { + t.Errorf("MCP = %q, want larksuite.com", ep.MCP) + } +} + +func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { + ep := ResolveEndpoints("") + if ep.Open != "https://open.feishu.cn" { + t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open) + } +} + +func TestResolveOpenBaseURL(t *testing.T) { + if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" { + t.Errorf("ResolveOpenBaseURL(feishu) = %q", got) + } + if got := ResolveOpenBaseURL(BrandLark); got != "https://open.larksuite.com" { + t.Errorf("ResolveOpenBaseURL(lark) = %q", got) + } +} diff --git a/internal/httpmock/registry.go b/internal/httpmock/registry.go new file mode 100644 index 00000000..2bdec941 --- /dev/null +++ b/internal/httpmock/registry.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package httpmock + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "testing" +) + +// Stub defines a preset HTTP response. +type Stub struct { + Method string // empty = match any method + URL string // substring match on URL + Status int // default 200 + Body interface{} // auto JSON-serialized + RawBody []byte // raw bytes (takes precedence over Body when non-nil) + ContentType string // override Content-Type header (default: application/json) + Headers http.Header // optional full response headers (takes precedence over ContentType) + matched bool + + // CapturedHeaders records the request headers of the matched request. + // Populated after RoundTrip matches this stub. + CapturedHeaders http.Header + CapturedBody []byte +} + +// Registry records stubs and implements http.RoundTripper. +type Registry struct { + mu sync.Mutex + stubs []*Stub +} + +// Register adds a stub to the registry. +func (r *Registry) Register(s *Stub) { + r.mu.Lock() + defer r.mu.Unlock() + if s.Status == 0 { + s.Status = 200 + } + r.stubs = append(r.stubs, s) +} + +// RoundTrip implements http.RoundTripper. +func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) { + urlStr := req.URL.String() + + r.mu.Lock() + var matched *Stub + for _, s := range r.stubs { + if s.matched { + continue + } + if s.Method != "" && s.Method != req.Method { + continue + } + if s.URL != "" && !strings.Contains(urlStr, s.URL) { + continue + } + s.matched = true + s.CapturedHeaders = req.Header.Clone() + if req.Body != nil { + s.CapturedBody, _ = io.ReadAll(req.Body) + req.Body = io.NopCloser(bytes.NewReader(s.CapturedBody)) + } + matched = s + break + } + r.mu.Unlock() + + if matched != nil { + resp, err := stubResponse(matched) + if err != nil { + return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err) + } + return resp, nil + } + return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL) +} + +// Verify asserts all stubs were matched. +func (r *Registry) Verify(t testing.TB) { + t.Helper() + r.mu.Lock() + defer r.mu.Unlock() + for _, s := range r.stubs { + if !s.matched { + t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL) + } + } +} + +func stubResponse(s *Stub) (*http.Response, error) { + ct := s.ContentType + if ct == "" { + ct = "application/json" + } + + var body io.ReadCloser + if s.RawBody != nil { + body = io.NopCloser(bytes.NewReader(s.RawBody)) + } else { + switch v := s.Body.(type) { + case string: + body = io.NopCloser(strings.NewReader(v)) + case []byte: + body = io.NopCloser(bytes.NewReader(v)) + default: + b, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("marshal body: %w", err) + } + body = io.NopCloser(bytes.NewReader(b)) + } + } + return &http.Response{ + StatusCode: s.Status, + Header: func() http.Header { + if s.Headers != nil { + return s.Headers.Clone() + } + return http.Header{"Content-Type": []string{ct}} + }(), + Body: body, + }, nil +} + +// NewClient returns an http.Client that uses the Registry as its transport. +func NewClient(reg *Registry) *http.Client { + return &http.Client{Transport: reg} +} diff --git a/internal/httpmock/registry_test.go b/internal/httpmock/registry_test.go new file mode 100644 index 00000000..ed8b9099 --- /dev/null +++ b/internal/httpmock/registry_test.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package httpmock + +import ( + "io" + "net/http" + "testing" +) + +func TestRegistry_RoundTrip(t *testing.T) { + reg := &Registry{} + reg.Register(&Stub{ + Method: "GET", + URL: "/open-apis/test", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + + client := NewClient(reg) + req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("want status 200, got %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + if got := string(body); got == "" { + t.Error("expected non-empty body") + } +} + +func TestRegistry_NoStub(t *testing.T) { + reg := &Registry{} + client := NewClient(reg) + req, _ := http.NewRequest("GET", "https://example.com/missing", nil) + _, err := client.Do(req) + if err == nil { + t.Fatal("expected error for unmatched request") + } +} + +func TestRegistry_MethodMismatch(t *testing.T) { + reg := &Registry{} + reg.Register(&Stub{ + Method: "POST", + URL: "/open-apis/test", + Body: "ok", + }) + + client := NewClient(reg) + req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil) + _, err := client.Do(req) + if err == nil { + t.Fatal("expected error for method mismatch") + } +} + +func TestRegistry_Verify_AllMatched(t *testing.T) { + reg := &Registry{} + reg.Register(&Stub{ + Method: "GET", + URL: "/used", + Body: "ok", + }) + + client := NewClient(reg) + req, _ := http.NewRequest("GET", "https://example.com/used", nil) + resp, _ := client.Do(req) + resp.Body.Close() + + reg.Verify(t) +} + +func TestRegistry_Verify_Unmatched(t *testing.T) { + reg := &Registry{} + reg.Register(&Stub{ + Method: "DELETE", + URL: "/unused", + Body: "ok", + }) + + fakeT := &testing.T{} + reg.Verify(fakeT) + if !fakeT.Failed() { + t.Error("Verify should report failure for unmatched stub") + } +} + +func TestRegistry_CustomStatus(t *testing.T) { + reg := &Registry{} + reg.Register(&Stub{ + URL: "/error", + Status: 500, + Body: `{"error":"internal"}`, + }) + + client := NewClient(reg) + req, _ := http.NewRequest("GET", "https://example.com/error", nil) + resp, err := client.Do(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 500 { + t.Errorf("want status 500, got %d", resp.StatusCode) + } +} diff --git a/internal/keychain/default.go b/internal/keychain/default.go new file mode 100644 index 00000000..5d9e3d10 --- /dev/null +++ b/internal/keychain/default.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package keychain + +import "fmt" + +// defaultKeychain implements KeychainAccess using the real platform keychain. +type defaultKeychain struct{} + +func (d *defaultKeychain) Get(service, account string) (string, error) { + val := Get(service, account) + if val == "" { + return "", fmt.Errorf("keychain entry not found: %s/%s", service, account) + } + return val, nil +} + +func (d *defaultKeychain) Set(service, account, value string) error { + return Set(service, account, value) +} + +func (d *defaultKeychain) Remove(service, account string) error { + return Remove(service, account) +} + +// Default returns a KeychainAccess backed by the real platform keychain. +func Default() KeychainAccess { + return &defaultKeychain{} +} diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go new file mode 100644 index 00000000..c225db8b --- /dev/null +++ b/internal/keychain/keychain.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package keychain provides cross-platform secure storage for secrets. +// macOS uses the system Keychain; Linux uses AES-256-GCM encrypted files; Windows uses DPAPI + registry. +package keychain + +const ( + // LarkCliService is the unified keychain service name for all secrets + // (both AppSecret and UAT). Entries are distinguished by account key format: + // - AppSecret: "appsecret:" + // - UAT: ":" + LarkCliService = "lark-cli" +) + +// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection. +// Used by AppSecret operations (ForStorage, ResolveSecretInput, RemoveSecretStore). +// UAT operations in token_store.go use the package-level Get/Set/Remove directly. +type KeychainAccess interface { + Get(service, account string) (string, error) + Set(service, account, value string) error + Remove(service, account string) error +} + +// Get retrieves a value from the keychain. +// Returns empty string if the entry does not exist. +func Get(service, account string) string { + return platformGet(service, account) +} + +// Set stores a value in the keychain, overwriting any existing entry. +func Set(service, account, data string) error { + return platformSet(service, account, data) +} + +// Remove deletes an entry from the keychain. No error if not found. +func Remove(service, account string) error { + return platformRemove(service, account) +} diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go new file mode 100644 index 00000000..fe71583d --- /dev/null +++ b/internal/keychain/keychain_darwin.go @@ -0,0 +1,179 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build darwin + +package keychain + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/google/uuid" + "github.com/zalando/go-keyring" +) + +const keychainTimeout = 5 * time.Second +const masterKeyBytes = 32 +const ivBytes = 12 +const tagBytes = 16 + +// StorageDir returns the storage directory for a given service name on macOS. +func StorageDir(service string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return filepath.Join(".lark-cli", "keychain", service) + } + return filepath.Join(home, "Library", "Application Support", service) +} + +var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func safeFileName(account string) string { + return safeFileNameRe.ReplaceAllString(account, "_") + ".enc" +} + +func getMasterKey(service string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout) + defer cancel() + + type result struct { + key []byte + err error + } + resCh := make(chan result, 1) + go func() { + defer func() { recover() }() + + encodedKey, err := keyring.Get(service, "master.key") + if err == nil { + key, decodeErr := base64.StdEncoding.DecodeString(encodedKey) + if decodeErr == nil && len(key) == masterKeyBytes { + resCh <- result{key: key, err: nil} + return + } + } + + // Generate new master key if not found or invalid + key := make([]byte, masterKeyBytes) + if _, randErr := rand.Read(key); randErr != nil { + resCh <- result{key: nil, err: randErr} + return + } + + encodedKey = base64.StdEncoding.EncodeToString(key) + setErr := keyring.Set(service, "master.key", encodedKey) + resCh <- result{key: key, err: setErr} + }() + + select { + case res := <-resCh: + return res.key, res.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func encryptData(plaintext string, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + iv := make([]byte, ivBytes) + if _, err := rand.Read(iv); err != nil { + return nil, err + } + + ciphertext := aesGCM.Seal(nil, iv, []byte(plaintext), nil) + result := make([]byte, 0, ivBytes+len(ciphertext)) + result = append(result, iv...) + result = append(result, ciphertext...) + return result, nil +} + +func decryptData(data []byte, key []byte) (string, error) { + if len(data) < ivBytes+tagBytes { + return "", os.ErrInvalid + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + iv := data[:ivBytes] + ciphertext := data[ivBytes:] + plaintext, err := aesGCM.Open(nil, iv, ciphertext, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +func platformGet(service, account string) string { + key, err := getMasterKey(service) + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account))) + if err != nil { + return "" + } + plaintext, err := decryptData(data, key) + if err != nil { + return "" + } + return plaintext +} + +func platformSet(service, account, data string) error { + key, err := getMasterKey(service) + if err != nil { + return err + } + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + encrypted, err := encryptData(data, key) + if err != nil { + return err + } + + targetPath := filepath.Join(dir, safeFileName(account)) + tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp") + defer os.Remove(tmpPath) + + if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil { + return err + } + + // Atomic rename to prevent file corruption during multi-process writes + if err := os.Rename(tmpPath, targetPath); err != nil { + return err + } + return nil +} + +func platformRemove(service, account string) error { + err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account))) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/internal/keychain/keychain_other.go b/internal/keychain/keychain_other.go new file mode 100644 index 00000000..631a9fb0 --- /dev/null +++ b/internal/keychain/keychain_other.go @@ -0,0 +1,176 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build linux + +package keychain + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/google/uuid" +) + +const masterKeyBytes = 32 +const ivBytes = 12 +const tagBytes = 16 + +// StorageDir returns the storage directory for a given service name. +// Each service gets its own directory for physical isolation. +func StorageDir(service string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + // If home is missing, fallback to relative path and print warning. + // This matches the behavior in internal/core/config.go. + fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) + } + xdgData := filepath.Join(home, ".local", "share") + return filepath.Join(xdgData, service) +} + +var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func safeFileName(account string) string { + return safeFileNameRe.ReplaceAllString(account, "_") + ".enc" +} + +func getMasterKey(service string) ([]byte, error) { + dir := StorageDir(service) + keyPath := filepath.Join(dir, "master.key") + + key, err := os.ReadFile(keyPath) + if err == nil && len(key) == masterKeyBytes { + return key, nil + } + + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + + key = make([]byte, masterKeyBytes) + if _, err := rand.Read(key); err != nil { + return nil, err + } + + tmpKeyPath := filepath.Join(dir, "master.key."+uuid.New().String()+".tmp") + defer os.Remove(tmpKeyPath) + + if err := os.WriteFile(tmpKeyPath, key, 0600); err != nil { + return nil, err + } + + // Atomic rename to prevent multi-process master key initialization collision + if err := os.Rename(tmpKeyPath, keyPath); err != nil { + // If rename fails, another process might have created it. Try reading again. + existingKey, readErr := os.ReadFile(keyPath) + if readErr == nil && len(existingKey) == masterKeyBytes { + return existingKey, nil + } + return nil, err + } + + return key, nil +} + +func encryptData(plaintext string, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + iv := make([]byte, ivBytes) + if _, err := rand.Read(iv); err != nil { + return nil, err + } + + ciphertext := aesGCM.Seal(nil, iv, []byte(plaintext), nil) + result := make([]byte, 0, ivBytes+len(ciphertext)) + result = append(result, iv...) + result = append(result, ciphertext...) + return result, nil +} + +func decryptData(data []byte, key []byte) (string, error) { + if len(data) < ivBytes+tagBytes { + return "", os.ErrInvalid + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + iv := data[:ivBytes] + ciphertext := data[ivBytes:] + plaintext, err := aesGCM.Open(nil, iv, ciphertext, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +func platformGet(service, account string) string { + key, err := getMasterKey(service) + if err != nil { + return "" + } + data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account))) + if err != nil { + return "" + } + plaintext, err := decryptData(data, key) + if err != nil { + return "" + } + return plaintext +} + +func platformSet(service, account, data string) error { + key, err := getMasterKey(service) + if err != nil { + return err + } + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + encrypted, err := encryptData(data, key) + if err != nil { + return err + } + + targetPath := filepath.Join(dir, safeFileName(account)) + tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp") + defer os.Remove(tmpPath) + + if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil { + return err + } + + // Atomic rename to prevent file corruption during multi-process writes + if err := os.Rename(tmpPath, targetPath); err != nil { + return err + } + return nil +} + +func platformRemove(service, account string) error { + err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account))) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/internal/keychain/keychain_windows.go b/internal/keychain/keychain_windows.go new file mode 100644 index 00000000..8830e8ac --- /dev/null +++ b/internal/keychain/keychain_windows.go @@ -0,0 +1,170 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build windows + +package keychain + +import ( + "encoding/base64" + "fmt" + "regexp" + "strings" + "unsafe" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" +) + +// --------------------------------------------------------------------------- +// Windows backend: DPAPI + HKCU registry +// --------------------------------------------------------------------------- + +const regRootPath = `Software\LarkCli\keychain` + +func registryPathForService(service string) string { + return regRootPath + `\` + safeRegistryComponent(service) +} + +var safeRegRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func safeRegistryComponent(s string) string { + // Registry key path uses '\\' separators; avoid accidental nesting and odd chars. + s = strings.ReplaceAll(s, "\\", "_") + return safeRegRe.ReplaceAllString(s, "_") +} + +func valueNameForAccount(account string) string { + // Avoid any special characters; keep deterministic. + return base64.RawURLEncoding.EncodeToString([]byte(account)) +} + +func dpapiEntropy(service, account string) *windows.DataBlob { + // Bind ciphertext to (service, account) to reduce swap/replay risks. + // Note: empty entropy is allowed, but we intentionally use deterministic entropy. + data := []byte(service + "\x00" + account) + if len(data) == 0 { + return nil + } + return &windows.DataBlob{Size: uint32(len(data)), Data: &data[0]} +} + +func dpapiProtect(plaintext []byte, entropy *windows.DataBlob) ([]byte, error) { + var in windows.DataBlob + if len(plaintext) > 0 { + in = windows.DataBlob{Size: uint32(len(plaintext)), Data: &plaintext[0]} + } + var out windows.DataBlob + err := windows.CryptProtectData(&in, nil, entropy, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out) + if err != nil { + return nil, err + } + defer freeDataBlob(&out) + + if out.Data == nil || out.Size == 0 { + return []byte{}, nil + } + buf := unsafe.Slice(out.Data, int(out.Size)) + res := make([]byte, len(buf)) + copy(res, buf) + return res, nil +} + +func dpapiUnprotect(ciphertext []byte, entropy *windows.DataBlob) ([]byte, error) { + var in windows.DataBlob + if len(ciphertext) > 0 { + in = windows.DataBlob{Size: uint32(len(ciphertext)), Data: &ciphertext[0]} + } + var out windows.DataBlob + err := windows.CryptUnprotectData(&in, nil, entropy, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out) + if err != nil { + return nil, err + } + defer freeDataBlob(&out) + + if out.Data == nil || out.Size == 0 { + return []byte{}, nil + } + buf := unsafe.Slice(out.Data, int(out.Size)) + res := make([]byte, len(buf)) + copy(res, buf) + return res, nil +} + +func freeDataBlob(b *windows.DataBlob) { + if b == nil || b.Data == nil { + return + } + // Per DPAPI contract, output buffers must be freed with LocalFree. + _, _ = windows.LocalFree(windows.Handle(unsafe.Pointer(b.Data))) + b.Data = nil + b.Size = 0 +} + +func platformGet(service, account string) string { + v, _ := registryGet(service, account) + return v +} + +func platformSet(service, account, data string) error { + entropy := dpapiEntropy(service, account) + protected, err := dpapiProtect([]byte(data), entropy) + if err != nil { + return fmt.Errorf("dpapi protect failed: %w", err) + } + return registrySet(service, account, protected) +} + +func platformRemove(service, account string) error { + return registryRemove(service, account) +} + +func registryGet(service, account string) (string, bool) { + keyPath := registryPathForService(service) + k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE) + if err != nil { + return "", false + } + defer k.Close() + + b64, _, err := k.GetStringValue(valueNameForAccount(account)) + if err != nil || b64 == "" { + return "", false + } + blob, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", false + } + entropy := dpapiEntropy(service, account) + plain, err := dpapiUnprotect(blob, entropy) + if err != nil { + return "", false + } + return string(plain), true +} + +func registrySet(service, account string, protected []byte) error { + keyPath := registryPathForService(service) + k, _, err := registry.CreateKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("registry create/open failed: %w", err) + } + defer k.Close() + + b64 := base64.StdEncoding.EncodeToString(protected) + if err := k.SetStringValue(valueNameForAccount(account), b64); err != nil { + return fmt.Errorf("registry set failed: %w", err) + } + return nil +} + +func registryRemove(service, account string) error { + keyPath := registryPathForService(service) + k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE) + if err != nil { + return nil + } + defer k.Close() + _ = k.DeleteValue(valueNameForAccount(account)) + return nil +} diff --git a/internal/lockfile/lock_unix.go b/internal/lockfile/lock_unix.go new file mode 100644 index 00000000..74670f15 --- /dev/null +++ b/internal/lockfile/lock_unix.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !windows + +package lockfile + +import ( + "fmt" + "os" + "syscall" +) + +func tryLockFile(f *os.File) error { + err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err != nil { + return fmt.Errorf("lock already held by another process (lock: %s): %w", f.Name(), err) + } + return nil +} + +func unlockFile(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} diff --git a/internal/lockfile/lock_windows.go b/internal/lockfile/lock_windows.go new file mode 100644 index 00000000..6daf9744 --- /dev/null +++ b/internal/lockfile/lock_windows.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build windows + +package lockfile + +import ( + "fmt" + "os" + "syscall" + "unsafe" +) + +var ( + modkernel32 = syscall.NewLazyDLL("kernel32.dll") + procLockFileEx = modkernel32.NewProc("LockFileEx") + procUnlockFile = modkernel32.NewProc("UnlockFileEx") +) + +const ( + lockfileExclusiveLock = 0x00000002 + lockfileFailImmediately = 0x00000001 +) + +func tryLockFile(f *os.File) error { + // OVERLAPPED structure (zeroed) + var ol syscall.Overlapped + handle := syscall.Handle(f.Fd()) + // LockFileEx(handle, flags, reserved, nNumberOfBytesToLockLow, nNumberOfBytesToLockHigh, *overlapped) + r1, _, err := procLockFileEx.Call( + uintptr(handle), + uintptr(lockfileExclusiveLock|lockfileFailImmediately), + 0, + 1, 0, + uintptr(unsafe.Pointer(&ol)), + ) + if r1 == 0 { + return fmt.Errorf("lock already held by another process (lock: %s): %v", f.Name(), err) + } + return nil +} + +func unlockFile(f *os.File) error { + var ol syscall.Overlapped + handle := syscall.Handle(f.Fd()) + r1, _, err := procUnlockFile.Call( + uintptr(handle), + 0, + 1, 0, + uintptr(unsafe.Pointer(&ol)), + ) + if r1 == 0 { + return err + } + return nil +} diff --git a/internal/lockfile/lockfile.go b/internal/lockfile/lockfile.go new file mode 100644 index 00000000..96563b9b --- /dev/null +++ b/internal/lockfile/lockfile.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lockfile + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/larksuite/cli/internal/core" +) + +// safeIDChars strips everything except alphanumerics, underscores, hyphens, and dots +// to prevent path traversal via crafted app IDs (e.g. "../../tmp/evil"). +var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +// LockFile represents an exclusive file lock. +type LockFile struct { + path string + file *os.File +} + +// New creates a LockFile for the given path (does not acquire the lock). +func New(path string) *LockFile { + return &LockFile{path: path} +} + +// ForSubscribe returns a LockFile scoped to the event subscribe command for a given App ID. +// Lock path: {configDir}/locks/subscribe_{appID}.lock +// +// The appID is sanitized to prevent path traversal: any character outside +// [a-zA-Z0-9._-] is replaced with "_", and filepath.Base strips directory +// components, so a malicious appID like "../../tmp/evil" becomes a flat +// filename under the locks directory. +func ForSubscribe(appID string) (*LockFile, error) { + if appID == "" { + return nil, fmt.Errorf("app ID must not be empty") + } + dir := filepath.Join(core.GetConfigDir(), "locks") + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("create lock dir: %w", err) + } + safe := safeIDChars.ReplaceAllString(appID, "_") + name := filepath.Base(fmt.Sprintf("subscribe_%s.lock", safe)) + path := filepath.Join(dir, name) + return New(path), nil +} + +// TryLock attempts to acquire an exclusive, non-blocking lock. +// Returns nil on success. Returns an error if the lock is already held +// by another process (or on any other failure). +// The lock is automatically released when the process exits. +func (l *LockFile) TryLock() error { + if l.file != nil { + return fmt.Errorf("lock already held: %s", l.path) + } + f, err := os.OpenFile(l.path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return fmt.Errorf("open lock file: %w", err) + } + if err := tryLockFile(f); err != nil { + f.Close() + return err + } + l.file = f + return nil +} + +// Unlock releases the lock and closes the file descriptor. +// The lock file is intentionally kept on disk to avoid an inode-reuse race: +// removing the path between unlock and a competing open+flock would let two +// processes lock different inodes under the same name. +func (l *LockFile) Unlock() error { + if l.file == nil { + return nil + } + err := unlockFile(l.file) + closeErr := l.file.Close() + l.file = nil + if err != nil { + return fmt.Errorf("unlock file: %w", err) + } + return closeErr +} + +// Path returns the lock file path. +func (l *LockFile) Path() string { + return l.path +} diff --git a/internal/lockfile/lockfile_test.go b/internal/lockfile/lockfile_test.go new file mode 100644 index 00000000..f6d50658 --- /dev/null +++ b/internal/lockfile/lockfile_test.go @@ -0,0 +1,197 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package lockfile + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func newTestLock(t *testing.T) *LockFile { + t.Helper() + return New(filepath.Join(t.TempDir(), "test.lock")) +} + +func TestTryLock_Success(t *testing.T) { + l := newTestLock(t) + + if err := l.TryLock(); err != nil { + t.Fatalf("TryLock failed: %v", err) + } + defer l.Unlock() + + if _, err := os.Stat(l.Path()); os.IsNotExist(err) { + t.Error("lock file should exist after TryLock") + } +} + +func TestTryLock_Conflict(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.lock") + + l1 := New(path) + if err := l1.TryLock(); err != nil { + t.Fatalf("first TryLock failed: %v", err) + } + defer l1.Unlock() + + l2 := New(path) + if err := l2.TryLock(); err == nil { + l2.Unlock() + t.Fatal("second TryLock should fail when lock is held by another instance") + } +} + +func TestTryLock_AlreadyHeld(t *testing.T) { + l := newTestLock(t) + + if err := l.TryLock(); err != nil { + t.Fatalf("TryLock failed: %v", err) + } + defer l.Unlock() + + err := l.TryLock() + if err == nil { + t.Fatal("double TryLock on same instance should fail") + } + if !strings.Contains(err.Error(), "lock already held") { + t.Errorf("error should mention 'lock already held', got: %v", err) + } +} + +func TestTryLock_InvalidPath(t *testing.T) { + l := New(filepath.Join(t.TempDir(), "no-such-dir", "test.lock")) + + err := l.TryLock() + if err == nil { + l.Unlock() + t.Fatal("TryLock should fail for non-existent parent directory") + } + if !strings.Contains(err.Error(), "open lock file") { + t.Errorf("error should mention 'open lock file', got: %v", err) + } +} + +func TestUnlock_ReleasesLock(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.lock") + + l1 := New(path) + if err := l1.TryLock(); err != nil { + t.Fatalf("TryLock failed: %v", err) + } + if err := l1.Unlock(); err != nil { + t.Fatalf("Unlock failed: %v", err) + } + + l2 := New(path) + if err := l2.TryLock(); err != nil { + t.Fatalf("TryLock after Unlock should succeed: %v", err) + } + defer l2.Unlock() +} + +func TestUnlock_KeepsFileOnDisk(t *testing.T) { + l := newTestLock(t) + + if err := l.TryLock(); err != nil { + t.Fatalf("TryLock failed: %v", err) + } + path := l.Path() + if err := l.Unlock(); err != nil { + t.Fatalf("Unlock failed: %v", err) + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Error("lock file should remain on disk after Unlock") + } +} + +func TestUnlock_Idempotent(t *testing.T) { + l := newTestLock(t) + + // Unlock without prior lock + if err := l.Unlock(); err != nil { + t.Fatalf("Unlock without lock should not error: %v", err) + } + + // Lock then double unlock + if err := l.TryLock(); err != nil { + t.Fatalf("TryLock failed: %v", err) + } + if err := l.Unlock(); err != nil { + t.Fatalf("first Unlock failed: %v", err) + } + if err := l.Unlock(); err != nil { + t.Fatalf("second Unlock should not error: %v", err) + } +} + +func TestPath(t *testing.T) { + l := New("/tmp/test.lock") + if l.Path() != "/tmp/test.lock" { + t.Errorf("Path() = %q, want /tmp/test.lock", l.Path()) + } +} + +func TestForSubscribe(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + l, err := ForSubscribe("cli_test123") + if err != nil { + t.Fatalf("ForSubscribe failed: %v", err) + } + + expected := filepath.Join(dir, "locks", "subscribe_cli_test123.lock") + if l.Path() != expected { + t.Errorf("Path() = %q, want %q", l.Path(), expected) + } + + lockDir := filepath.Join(dir, "locks") + if _, err := os.Stat(lockDir); os.IsNotExist(err) { + t.Error("locks directory should be created by ForSubscribe") + } +} + +func TestForSubscribe_SanitizesAppID(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + + for _, tt := range []struct { + name string + appID string + wantBase string + }{ + {"path traversal", "../../tmp/evil", "subscribe_.._.._tmp_evil.lock"}, + {"slashes", "cli/app/id", "subscribe_cli_app_id.lock"}, + {"normal id", "cli_a1b2c3", "subscribe_cli_a1b2c3.lock"}, + {"special chars", "app@id:123", "subscribe_app_id_123.lock"}, + } { + t.Run(tt.name, func(t *testing.T) { + l, err := ForSubscribe(tt.appID) + if err != nil { + t.Fatalf("ForSubscribe(%q) failed: %v", tt.appID, err) + } + gotBase := filepath.Base(l.Path()) + if gotBase != tt.wantBase { + t.Errorf("Base(Path()) = %q, want %q", gotBase, tt.wantBase) + } + // Lock file must always be under the locks directory + locksDir := filepath.Join(dir, "locks") + if !strings.HasPrefix(l.Path(), locksDir) { + t.Errorf("path %q escapes locks dir %q", l.Path(), locksDir) + } + }) + } +} + +func TestForSubscribe_RejectsEmptyAppID(t *testing.T) { + _, err := ForSubscribe("") + if err == nil { + t.Fatal("ForSubscribe should reject empty app ID") + } +} diff --git a/internal/output/colors.go b/internal/output/colors.go new file mode 100644 index 00000000..6a74f311 --- /dev/null +++ b/internal/output/colors.go @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +const ( + Dim = "\033[2m" + Bold = "\033[1m" + Yellow = "\033[33m" + Cyan = "\033[36m" + Red = "\033[31m" + Green = "\033[32m" + Reset = "\033[0m" +) diff --git a/internal/output/csv.go b/internal/output/csv.go new file mode 100644 index 00000000..22be2b26 --- /dev/null +++ b/internal/output/csv.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "encoding/csv" + "fmt" + "io" + "os" +) + +// FormatAsCSV formats data as CSV (with header) and writes it to w. +func FormatAsCSV(w io.Writer, data interface{}) { + FormatAsCSVPaginated(w, data, true) +} + +// FormatAsCSVPaginated formats data as CSV with pagination awareness. +// When isFirstPage is true, outputs the header row; otherwise only data rows. +func FormatAsCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) { + rows, cols, isList := prepareRows(data) + if cols == nil { + if isList { + fmt.Fprintln(w, "(empty)") + } else { + PrintJson(w, data) + } + return + } + + if len(rows) == 0 { + if isFirstPage { + fmt.Fprintln(w, "(empty)") + } + return + } + + if !isList { + // Single object: key,value rows + cw := csv.NewWriter(w) + if isFirstPage { + cw.Write([]string{"key", "value"}) + } + for _, col := range cols { + cw.Write([]string{col, rows[0][col]}) + } + flushCSV(cw) + return + } + + writeCSVRows(w, rows, cols, isFirstPage) +} + +// writeCSVRows writes CSV data rows (and optionally header) using the given columns. +func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) { + cw := csv.NewWriter(w) + if writeHeader { + cw.Write(cols) + } + for _, row := range rows { + record := make([]string, len(cols)) + for i, col := range cols { + record[i] = row[col] + } + cw.Write(record) + } + flushCSV(cw) +} + +// flushCSV flushes the csv.Writer and reports any write error to stderr. +func flushCSV(cw *csv.Writer) { + cw.Flush() + if err := cw.Error(); err != nil { + fmt.Fprintf(os.Stderr, "csv write error: %v\n", err) + } +} diff --git a/internal/output/csv_test.go b/internal/output/csv_test.go new file mode 100644 index 00000000..d22248a4 --- /dev/null +++ b/internal/output/csv_test.go @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestFormatAsCSV_BasicArray(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"name": "Alice", "age": float64(30)}, + map[string]interface{}{"name": "Bob", "age": float64(25)}, + } + + var buf bytes.Buffer + FormatAsCSV(&buf, data) + out := buf.String() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + + if len(lines) != 3 { + t.Fatalf("expected 3 lines (header + 2 rows), got %d:\n%s", len(lines), out) + } + + // Header should contain both column names + header := lines[0] + if !strings.Contains(header, "name") || !strings.Contains(header, "age") { + t.Errorf("header should contain 'name' and 'age', got: %s", header) + } +} + +func TestFormatAsCSV_RFC4180Escaping(t *testing.T) { + data := []interface{}{ + map[string]interface{}{ + "text": `hello, "world"`, + }, + } + + var buf bytes.Buffer + FormatAsCSV(&buf, data) + out := buf.String() + + // RFC 4180: fields with commas/quotes are quoted, internal quotes are doubled + if !strings.Contains(out, `"hello, ""world"""`) { + t.Errorf("CSV should properly escape commas and quotes, got:\n%s", out) + } +} + +func TestFormatAsCSV_NewlineInValue(t *testing.T) { + data := []interface{}{ + map[string]interface{}{ + "text": "line1\nline2", + }, + } + + var buf bytes.Buffer + FormatAsCSV(&buf, data) + out := buf.String() + + // RFC 4180: fields with newlines should be quoted + if !strings.Contains(out, `"line1`) { + t.Errorf("CSV should quote fields containing newlines, got:\n%s", out) + } +} + +func TestFormatAsCSV_NestedObject(t *testing.T) { + data := []interface{}{ + map[string]interface{}{ + "user": map[string]interface{}{ + "name": "Alice", + }, + "id": float64(1), + }, + } + + var buf bytes.Buffer + FormatAsCSV(&buf, data) + out := buf.String() + + if !strings.Contains(out, "user.name") { + t.Errorf("CSV should contain flattened 'user.name' column, got:\n%s", out) + } +} + +func TestFormatAsCSV_EmptyArray(t *testing.T) { + data := []interface{}{} + + var buf bytes.Buffer + FormatAsCSV(&buf, data) + out := strings.TrimSpace(buf.String()) + + if out != "(empty)" { + t.Errorf("empty array should output '(empty)', got:\n%s", out) + } +} + +func TestFormatAsCSVPaginated_FirstPage(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"name": "Alice"}, + } + + var buf bytes.Buffer + FormatAsCSVPaginated(&buf, data, true) + out := buf.String() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + + if len(lines) != 2 { + t.Errorf("first page should have header + 1 data row, got %d lines:\n%s", len(lines), out) + } + if lines[0] != "name" { + t.Errorf("first line should be header 'name', got: %s", lines[0]) + } +} + +func TestFormatAsCSVPaginated_ContinuationPage(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"name": "Bob"}, + } + + var buf bytes.Buffer + FormatAsCSVPaginated(&buf, data, false) + out := buf.String() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + + if len(lines) != 1 { + t.Errorf("continuation page should have 1 data row, got %d lines:\n%s", len(lines), out) + } + if lines[0] != "Bob" { + t.Errorf("continuation page data should be 'Bob', got: %s", lines[0]) + } +} + +func TestFormatAsCSV_SingleObject(t *testing.T) { + data := map[string]interface{}{ + "name": "Alice", + "age": float64(30), + } + + var buf bytes.Buffer + FormatAsCSV(&buf, data) + out := buf.String() + + // Single object should render as key,value format + if !strings.Contains(out, "key,value") { + t.Errorf("single object should have key,value header, got:\n%s", out) + } + if !strings.Contains(out, "Alice") { + t.Errorf("output should contain 'Alice', got:\n%s", out) + } +} diff --git a/internal/output/envelope.go b/internal/output/envelope.go new file mode 100644 index 00000000..21caefab --- /dev/null +++ b/internal/output/envelope.go @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +// Envelope is the standard success response wrapper. +type Envelope struct { + OK bool `json:"ok"` + Identity string `json:"identity,omitempty"` + Data interface{} `json:"data,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +// ErrorEnvelope is the standard error response wrapper. +type ErrorEnvelope struct { + OK bool `json:"ok"` + Identity string `json:"identity,omitempty"` + Error *ErrDetail `json:"error"` + Meta *Meta `json:"meta,omitempty"` +} + +// ErrDetail describes a structured error. +type ErrDetail struct { + Type string `json:"type"` + Code int `json:"code,omitempty"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + ConsoleURL string `json:"console_url,omitempty"` + Detail interface{} `json:"detail,omitempty"` +} + +// Meta carries optional metadata in envelope responses. +type Meta struct { + Count int `json:"count,omitempty"` + Rollback string `json:"rollback,omitempty"` +} diff --git a/internal/output/errors.go b/internal/output/errors.go new file mode 100644 index 00000000..e61c9b27 --- /dev/null +++ b/internal/output/errors.go @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" +) + +// ExitError is a structured error that carries an exit code and optional detail. +// It is propagated up the call chain and handled by main.go to produce +// a JSON error envelope on stderr and the correct exit code. +type ExitError struct { + Code int + Detail *ErrDetail + Err error + Raw bool // when true, skip enrichment (e.g. enrichPermissionError) and preserve original error +} + +func (e *ExitError) Error() string { + if e.Detail != nil { + return e.Detail.Message + } + if e.Err != nil { + return e.Err.Error() + } + return fmt.Sprintf("exit %d", e.Code) +} + +func (e *ExitError) Unwrap() error { + return e.Err +} + +// WriteErrorEnvelope writes a JSON error envelope for the given ExitError to w. +func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) { + if err.Detail == nil { + return + } + env := ErrorEnvelope{ + OK: false, + Identity: identity, + Error: err.Detail, + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(env); err != nil { + return + } + // Encode appends a trailing newline; write directly. + buf.WriteTo(w) +} + +// --- Convenience constructors --- + +// Errorf creates an ExitError with the given code, type, and formatted message. +func Errorf(code int, errType, format string, args ...any) *ExitError { + var err error + for _, arg := range args { + if e, ok := arg.(error); ok { + err = e + break + } + } + return &ExitError{ + Code: code, + Detail: &ErrDetail{Type: errType, Message: fmt.Sprintf(format, args...)}, + Err: err, + } +} + +// ErrValidation creates a validation ExitError (exit 2). +func ErrValidation(format string, args ...any) *ExitError { + return Errorf(ExitValidation, "validation", format, args...) +} + +// ErrAuth creates an auth ExitError (exit 3). +func ErrAuth(format string, args ...any) *ExitError { + return Errorf(ExitAuth, "auth", format, args...) +} + +// ErrNetwork creates a network ExitError (exit 4). +func ErrNetwork(format string, args ...any) *ExitError { + return Errorf(ExitNetwork, "network", format, args...) +} + +// ErrAPI creates an API ExitError using ClassifyLarkError. +// For permission errors, uses a concise message; the raw API response is preserved in Detail. +func ErrAPI(larkCode int, msg string, detail any) *ExitError { + exitCode, errType, hint := ClassifyLarkError(larkCode, msg) + if errType == "permission" { + msg = fmt.Sprintf("Permission denied [%d]", larkCode) + } + return &ExitError{ + Code: exitCode, + Detail: &ErrDetail{ + Type: errType, + Code: larkCode, + Message: msg, + Hint: hint, + Detail: detail, + }, + } +} + +// ErrWithHint creates an ExitError with a hint string. +func ErrWithHint(code int, errType, msg, hint string) *ExitError { + return &ExitError{ + Code: code, + Detail: &ErrDetail{Type: errType, Message: msg, Hint: hint}, + } +} + +// ErrBare creates an ExitError with only an exit code and no envelope. +// Used for cases like `auth check` where the JSON output is already written to stdout. +func ErrBare(code int) *ExitError { + return &ExitError{Code: code} +} + +// MarkRaw sets Raw=true on an ExitError so that enrichment (e.g. enrichPermissionError) +// is skipped and the original API error is preserved. Returns the original error unchanged +// if it is not an ExitError. +func MarkRaw(err error) error { + var exitErr *ExitError + if errors.As(err, &exitErr) { + exitErr.Raw = true + } + return err +} diff --git a/internal/output/errors_test.go b/internal/output/errors_test.go new file mode 100644 index 00000000..2cc3d1f2 --- /dev/null +++ b/internal/output/errors_test.go @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "fmt" + "testing" +) + +func TestMarkRaw_ExitError(t *testing.T) { + err := ErrAPI(99991672, "API error: [99991672] scope not enabled", nil) + if err.Raw { + t.Fatal("expected Raw=false before MarkRaw") + } + + result := MarkRaw(err) + if result != err { + t.Error("expected MarkRaw to return the same error") + } + if !err.Raw { + t.Error("expected Raw=true after MarkRaw") + } +} + +func TestMarkRaw_NonExitError(t *testing.T) { + plain := fmt.Errorf("some plain error") + result := MarkRaw(plain) + if result != plain { + t.Error("expected MarkRaw to return the same error for non-ExitError") + } +} + +func TestMarkRaw_Nil(t *testing.T) { + result := MarkRaw(nil) + if result != nil { + t.Error("expected MarkRaw(nil) to return nil") + } +} diff --git a/internal/output/exitcode.go b/internal/output/exitcode.go new file mode 100644 index 00000000..47628afd --- /dev/null +++ b/internal/output/exitcode.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +// Fine-grained error types (permission, not_found, rate_limit, etc.) +// are communicated via the JSON error envelope's "type" field, +// not via exit codes. +const ( + ExitOK = 0 // 成功 + ExitAPI = 1 // API / 通用错误(含 permission、not_found、conflict、rate_limit) + ExitValidation = 2 // 参数校验失败 + ExitAuth = 3 // 认证失败(token 无效 / 过期) + ExitNetwork = 4 // 网络错误(连接超时、DNS 解析失败等) + ExitInternal = 5 // 内部错误(不应发生) +) diff --git a/internal/output/flatten.go b/internal/output/flatten.go new file mode 100644 index 00000000..594de64e --- /dev/null +++ b/internal/output/flatten.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "sort" + "unicode/utf8" +) + +const maxFlattenDepth = 3 + +type flatEntry struct { + Key string + Value string +} + +// flattenObject flattens a nested object into dot-notation key-value pairs. +// Objects nested beyond maxFlattenDepth levels are serialized as JSON strings. +// Keys are sorted alphabetically for deterministic column order. +func flattenObject(obj map[string]interface{}, prefix string, depth int) []flatEntry { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + + var entries []flatEntry + for _, k := range keys { + v := obj[k] + key := k + if prefix != "" { + key = prefix + "." + k + } + switch val := v.(type) { + case map[string]interface{}: + if depth+1 >= maxFlattenDepth { + entries = append(entries, flatEntry{Key: key, Value: cellStr(val)}) + } else { + entries = append(entries, flattenObject(val, key, depth+1)...) + } + default: + entries = append(entries, flatEntry{Key: key, Value: cellStr(v)}) + } + } + return entries +} + +// collectColumns collects column names from all rows (union set), +// preserving first-occurrence order. +func collectColumns(rows [][]flatEntry) []string { + seen := map[string]bool{} + var cols []string + for _, row := range rows { + for _, e := range row { + if !seen[e.Key] { + seen[e.Key] = true + cols = append(cols, e.Key) + } + } + } + return cols +} + +// rowMap converts a slice of flatEntry into a map for column lookup. +func rowMap(entries []flatEntry) map[string]string { + m := make(map[string]string, len(entries)) + for _, e := range entries { + m[e.Key] = e.Value + } + return m +} + +// runeWidth returns the display width of a rune. +// CJK characters and some symbols are double-width. +func runeWidth(r rune) int { + if r == utf8.RuneError { + return 1 + } + // CJK Unified Ideographs, CJK Compatibility Ideographs, etc. + if (r >= 0x1100 && r <= 0x115F) || // Hangul Jamo + r == 0x2329 || r == 0x232A || + (r >= 0x2E80 && r <= 0x303E) || // CJK Radicals, Kangxi, CJK Symbols + (r >= 0x3040 && r <= 0x33BF) || // Hiragana, Katakana, Bopomofo, etc. + (r >= 0x3400 && r <= 0x4DBF) || // CJK Unified Ideographs Extension A + (r >= 0x4E00 && r <= 0xA4CF) || // CJK Unified Ideographs, Yi + (r >= 0xA960 && r <= 0xA97C) || // Hangul Jamo Extended-A + (r >= 0xAC00 && r <= 0xD7A3) || // Hangul Syllables + (r >= 0xF900 && r <= 0xFAFF) || // CJK Compatibility Ideographs + (r >= 0xFE10 && r <= 0xFE6F) || // CJK Compatibility Forms, Small Forms + (r >= 0xFF01 && r <= 0xFF60) || // Fullwidth Forms + (r >= 0xFFE0 && r <= 0xFFE6) || // Fullwidth Signs + (r >= 0x1F300 && r <= 0x1F9FF) || // Emoji (Miscellaneous Symbols and Pictographs, Emoticons, etc.) + (r >= 0x20000 && r <= 0x2FFFF) || // CJK Unified Ideographs Extension B-F + (r >= 0x30000 && r <= 0x3FFFF) { // CJK Unified Ideographs Extension G+ + return 2 + } + return 1 +} + +// stringWidth returns the display width of a string. +func stringWidth(s string) int { + w := 0 + for _, r := range s { + w += runeWidth(r) + } + return w +} + +// truncateToWidth truncates a string to fit within maxWidth display columns. +// If truncated, appends "…". +func truncateToWidth(s string, maxWidth int) string { + if maxWidth <= 0 { + return "" + } + w := 0 + for i, r := range s { + rw := runeWidth(r) + if w+rw > maxWidth { + return s[:i] + "…" + } + w += rw + } + return s +} + +// flattenItem flattens a single item (object or other) into flatEntry pairs. +func flattenItem(item interface{}) []flatEntry { + if obj, ok := item.(map[string]interface{}); ok { + return flattenObject(obj, "", 0) + } + return []flatEntry{{Key: "value", Value: cellStr(item)}} +} + +// prepareRows converts a data value into flattened rows and column names. +// Returns rows (as maps), columns, and whether the data was a list. +func prepareRows(data interface{}) (rows []map[string]string, cols []string, isList bool) { + items := extractArray(data) + if items == nil { + // Single object + if obj, ok := data.(map[string]interface{}); ok { + entries := flattenObject(obj, "", 0) + rm := rowMap(entries) + flatRows := [][]flatEntry{entries} + return []map[string]string{rm}, collectColumns(flatRows), false + } + return nil, nil, false + } + + isList = true + var flatRows [][]flatEntry + for _, item := range items { + entries := flattenItem(item) + flatRows = append(flatRows, entries) + rows = append(rows, rowMap(entries)) + } + cols = collectColumns(flatRows) + return rows, cols, isList +} + +// extractArray extracts an array from data, or returns nil. +func extractArray(data interface{}) []interface{} { + if arr, ok := data.([]interface{}); ok { + return arr + } + return nil +} diff --git a/internal/output/flatten_test.go b/internal/output/flatten_test.go new file mode 100644 index 00000000..46ef95b3 --- /dev/null +++ b/internal/output/flatten_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "testing" +) + +func TestFlattenObjectSimple(t *testing.T) { + obj := map[string]interface{}{ + "name": "Alice", + "age": float64(30), + } + entries := flattenObject(obj, "", 0) + m := rowMap(entries) + + if m["name"] != "Alice" { + t.Errorf("name = %q, want %q", m["name"], "Alice") + } + if m["age"] != "30" { + t.Errorf("age = %q, want %q", m["age"], "30") + } +} + +func TestFlattenObjectNested(t *testing.T) { + obj := map[string]interface{}{ + "user": map[string]interface{}{ + "name": "Alice", + "addr": map[string]interface{}{ + "city": "Beijing", + }, + }, + } + entries := flattenObject(obj, "", 0) + m := rowMap(entries) + + if m["user.name"] != "Alice" { + t.Errorf("user.name = %q, want %q", m["user.name"], "Alice") + } + if m["user.addr.city"] != "Beijing" { + t.Errorf("user.addr.city = %q, want %q", m["user.addr.city"], "Beijing") + } +} + +func TestFlattenObjectDeepLimit(t *testing.T) { + // Create depth=4 nesting — should serialize the innermost object as JSON + obj := map[string]interface{}{ + "a": map[string]interface{}{ + "b": map[string]interface{}{ + "c": map[string]interface{}{ + "d": "deep", + }, + }, + }, + } + entries := flattenObject(obj, "", 0) + m := rowMap(entries) + + // depth 0 → a (map), depth 1 → b (map), depth 2 → c (map), depth 3 ≥ maxFlattenDepth → serialize + if v, ok := m["a.b.c"]; !ok { + t.Errorf("expected key a.b.c, got keys: %v", m) + } else if v != `{"d":"deep"}` { + t.Errorf("a.b.c = %q, want JSON string", v) + } +} + +func TestFlattenObjectArrayLeaf(t *testing.T) { + obj := map[string]interface{}{ + "tags": []interface{}{"a", "b"}, + } + entries := flattenObject(obj, "", 0) + m := rowMap(entries) + + if m["tags"] != `["a","b"]` { + t.Errorf("tags = %q, want %q", m["tags"], `["a","b"]`) + } +} + +func TestFlattenObjectNilValue(t *testing.T) { + obj := map[string]interface{}{ + "empty": nil, + } + entries := flattenObject(obj, "", 0) + m := rowMap(entries) + + if m["empty"] != "" { + t.Errorf("empty = %q, want %q", m["empty"], "") + } +} + +func TestCollectColumns(t *testing.T) { + rows := [][]flatEntry{ + {{Key: "a", Value: "1"}, {Key: "b", Value: "2"}}, + {{Key: "b", Value: "3"}, {Key: "c", Value: "4"}}, + } + cols := collectColumns(rows) + + // Should contain a, b, c (union) + colSet := map[string]bool{} + for _, c := range cols { + colSet[c] = true + } + for _, expected := range []string{"a", "b", "c"} { + if !colSet[expected] { + t.Errorf("missing column %q in %v", expected, cols) + } + } + if len(cols) != 3 { + t.Errorf("got %d columns, want 3", len(cols)) + } +} + +func TestTruncateToWidth(t *testing.T) { + tests := []struct { + input string + maxWidth int + want string + }{ + {"hello", 10, "hello"}, + {"hello", 5, "hello"}, + {"hello", 4, "hell…"}, + {"hello", 3, "hel…"}, + {"hello", 1, "h…"}, + {"hello", 0, ""}, + // CJK: each char is width 2 + {"你好世界", 8, "你好世界"}, + {"你好世界", 6, "你好世…"}, + {"你好世界", 4, "你好…"}, + {"你好世界", 3, "你…"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := truncateToWidth(tt.input, tt.maxWidth) + if got != tt.want { + t.Errorf("truncateToWidth(%q, %d) = %q, want %q", tt.input, tt.maxWidth, got, tt.want) + } + }) + } +} + +func TestStringWidth(t *testing.T) { + tests := []struct { + input string + want int + }{ + {"hello", 5}, + {"你好", 4}, + {"ab你好cd", 8}, + {"", 0}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := stringWidth(tt.input) + if got != tt.want { + t.Errorf("stringWidth(%q) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/output/format.go b/internal/output/format.go new file mode 100644 index 00000000..a05a2ea1 --- /dev/null +++ b/internal/output/format.go @@ -0,0 +1,195 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "sort" +) + +// Known array field names for pagination. +var knownArrayFields = []string{ + "items", "files", "events", "rooms", "records", "nodes", + "members", "departments", "calendar_list", "acl_list", "freebusy_list", +} + +// FindArrayField finds the primary array field in a response's data object. +// It first checks knownArrayFields in priority order, then falls back to +// the lexicographically smallest unknown array field for deterministic results. +func FindArrayField(data map[string]interface{}) string { + for _, name := range knownArrayFields { + if arr, ok := data[name]; ok { + if _, isArr := arr.([]interface{}); isArr { + return name + } + } + } + // Fallback: lexicographically first array field (deterministic) + var candidates []string + for k, v := range data { + if _, isArr := v.([]interface{}); isArr { + candidates = append(candidates, k) + } + } + if len(candidates) > 0 { + sort.Strings(candidates) + return candidates[0] + } + return "" +} + +// toGeneric normalises any Go value (structs, typed slices, …) into +// plain map[string]interface{} / []interface{} via a JSON round-trip so +// that subsequent type assertions in format handlers work uniformly. +func toGeneric(v interface{}) interface{} { + switch v.(type) { + case map[string]interface{}, []interface{}, nil: + return v // already generic + } + b, err := json.Marshal(v) + if err != nil { + return v + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.UseNumber() // preserve int64 precision (avoid float64 truncation) + var out interface{} + if err := dec.Decode(&out); err != nil { + return v + } + return out +} + +// ExtractItems extracts the data array from a response. +// It tries two strategies in order: +// 1. Lark API envelope: result["data"][arrayField] (e.g. {"code":0,"data":{"items":[…]}}) +// 2. Direct map: result[arrayField] (e.g. {"members":[…],"total":5}) +// +// If data is already a plain []interface{}, it is returned as-is. +func ExtractItems(data interface{}) []interface{} { + resultMap, ok := data.(map[string]interface{}) + if !ok { + if arr, ok := data.([]interface{}); ok { + return arr + } + return nil + } + + // Strategy 1: Lark API envelope — result["data"][arrayField] + if dataObj, ok := resultMap["data"].(map[string]interface{}); ok { + if field := FindArrayField(dataObj); field != "" { + if items, ok := dataObj[field].([]interface{}); ok { + return items + } + } + } + + // Strategy 2: direct map — result[arrayField] + // Covers shortcut-level data like {"members":[…], "total":5, "has_more":false} + if field := FindArrayField(resultMap); field != "" { + if items, ok := resultMap[field].([]interface{}); ok { + return items + } + } + + return nil +} + +// FormatValue formats a single response and writes it to w. +func FormatValue(w io.Writer, data interface{}, format Format) { + data = toGeneric(data) + switch format { + case FormatNDJSON: + items := ExtractItems(data) + if items != nil { + PrintNdjson(w, items) + } else { + PrintNdjson(w, data) + } + + case FormatTable: + items := ExtractItems(data) + if items != nil { + FormatAsTable(w, items) + } else { + FormatAsTable(w, data) + } + + case FormatCSV: + items := ExtractItems(data) + if items != nil { + FormatAsCSV(w, items) + } else { + FormatAsCSV(w, data) + } + + default: // FormatJSON + PrintJson(w, data) + } +} + +// PaginatedFormatter holds state across paginated calls to ensure +// consistent columns (table/csv use the first page's columns for all pages). +type PaginatedFormatter struct { + W io.Writer + Format Format + isFirstPage bool + cols []string // locked after first page +} + +// NewPaginatedFormatter creates a formatter that tracks pagination state. +func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter { + return &PaginatedFormatter{W: w, Format: format, isFirstPage: true} +} + +// FormatPage formats one page of items. +func (pf *PaginatedFormatter) FormatPage(data interface{}) { + switch pf.Format { + case FormatJSON, FormatNDJSON: + if arr, ok := data.([]interface{}); ok { + PrintNdjson(pf.W, arr) + } else { + PrintNdjson(pf.W, data) + } + + case FormatTable: + pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) { + widths := computeColumnWidths(rows, cols) + if isFirst { + writeHeader(w, cols, widths) + } + for _, row := range rows { + writeRow(w, row, cols, widths) + } + }) + + case FormatCSV: + pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) { + writeCSVRows(w, rows, cols, isFirst) + }) + } +} + +// formatStructuredPage handles column-locking logic shared by table and csv. +func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool)) { + rows, pageCols, isList := prepareRows(data) + if len(rows) == 0 { + if pf.isFirstPage && isList { + fmt.Fprintln(pf.W, "(empty)") + } + return + } + + if pf.isFirstPage { + // Lock columns from first page + pf.cols = pageCols + pf.isFirstPage = false + emit(pf.W, rows, pf.cols, true) + } else { + // Reuse first page's columns — missing keys become empty, extra keys ignored + emit(pf.W, rows, pf.cols, false) + } +} diff --git a/internal/output/format_test.go b/internal/output/format_test.go new file mode 100644 index 00000000..f8603456 --- /dev/null +++ b/internal/output/format_test.go @@ -0,0 +1,301 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestFormatValue_JSON(t *testing.T) { + data := map[string]interface{}{"name": "Alice"} + + var buf bytes.Buffer + FormatValue(&buf, data, FormatJSON) + out := buf.String() + + // Should be pretty-printed JSON + if !strings.Contains(out, `"name"`) { + t.Errorf("JSON output should contain field name, got:\n%s", out) + } + if !strings.Contains(out, "Alice") { + t.Errorf("JSON output should contain value, got:\n%s", out) + } +} + +func TestFormatValue_NDJSON(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"id": float64(1)}, + map[string]interface{}{"id": float64(2)}, + }, + }, + } + + var buf bytes.Buffer + FormatValue(&buf, data, FormatNDJSON) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + + if len(lines) != 2 { + t.Fatalf("NDJSON should output 2 lines, got %d:\n%s", len(lines), buf.String()) + } + + for _, line := range lines { + var obj map[string]interface{} + if err := json.Unmarshal([]byte(line), &obj); err != nil { + t.Errorf("each NDJSON line should be valid JSON: %s", line) + } + } +} + +func TestFormatValue_Table(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice"}, + }, + }, + } + + var buf bytes.Buffer + FormatValue(&buf, data, FormatTable) + out := buf.String() + + if !strings.Contains(out, "name") { + t.Errorf("table output should contain 'name' header, got:\n%s", out) + } + if !strings.Contains(out, "Alice") { + t.Errorf("table output should contain 'Alice', got:\n%s", out) + } +} + +func TestFormatValue_CSV(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice"}, + }, + }, + } + + var buf bytes.Buffer + FormatValue(&buf, data, FormatCSV) + out := buf.String() + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + + if len(lines) != 2 { + t.Fatalf("CSV should have header + 1 row, got %d lines:\n%s", len(lines), out) + } + if lines[0] != "name" { + t.Errorf("CSV header should be 'name', got: %s", lines[0]) + } + if lines[1] != "Alice" { + t.Errorf("CSV row should be 'Alice', got: %s", lines[1]) + } +} + +func TestPaginatedFormatter_JSON(t *testing.T) { + var buf bytes.Buffer + pf := NewPaginatedFormatter(&buf, FormatJSON) + + pf.FormatPage([]interface{}{ + map[string]interface{}{"id": float64(1)}, + map[string]interface{}{"id": float64(2)}, + }) + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 2 { + t.Errorf("paginated JSON should emit 2 lines (NDJSON), got %d:\n%s", len(lines), buf.String()) + } +} + +func TestPaginatedFormatter_NDJSON(t *testing.T) { + var buf bytes.Buffer + pf := NewPaginatedFormatter(&buf, FormatNDJSON) + + pf.FormatPage([]interface{}{map[string]interface{}{"id": float64(1)}}) + out := strings.TrimSpace(buf.String()) + + var obj map[string]interface{} + if err := json.Unmarshal([]byte(out), &obj); err != nil { + t.Errorf("NDJSON paginated output should be valid JSON: %s", out) + } +} + +func TestPaginatedFormatter_Table(t *testing.T) { + var buf bytes.Buffer + pf := NewPaginatedFormatter(&buf, FormatTable) + + page1 := []interface{}{map[string]interface{}{"name": "Alice"}} + page2 := []interface{}{map[string]interface{}{"name": "Bob"}} + + pf.FormatPage(page1) + out1 := buf.String() + if !strings.Contains(out1, "─") { + t.Error("first table page should contain separator") + } + + buf.Reset() + pf.FormatPage(page2) + out2 := buf.String() + if strings.Contains(out2, "─") { + t.Error("continuation table page should not contain separator") + } + if !strings.Contains(out2, "Bob") { + t.Error("continuation table page should contain data") + } +} + +func TestPaginatedFormatter_CSV(t *testing.T) { + var buf bytes.Buffer + pf := NewPaginatedFormatter(&buf, FormatCSV) + + page1 := []interface{}{map[string]interface{}{"name": "Alice"}} + page2 := []interface{}{map[string]interface{}{"name": "Bob"}} + + pf.FormatPage(page1) + lines1 := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines1) != 2 { + t.Errorf("first CSV page should have header + data, got %d lines", len(lines1)) + } + + buf.Reset() + pf.FormatPage(page2) + lines2 := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines2) != 1 { + t.Errorf("continuation CSV page should have only data, got %d lines", len(lines2)) + } +} + +func TestPaginatedFormatter_ColumnConsistency(t *testing.T) { + // Page 1 has {a, b}, page 2 has {a, b, c} — c should be ignored in CSV + var buf bytes.Buffer + pf := NewPaginatedFormatter(&buf, FormatCSV) + + pf.FormatPage([]interface{}{map[string]interface{}{"a": "1", "b": "2"}}) + header := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")[0] + + buf.Reset() + pf.FormatPage([]interface{}{map[string]interface{}{"a": "3", "b": "4", "c": "5"}}) + dataLine := strings.TrimRight(buf.String(), "\n") + + // Header and data should have same number of columns + headerCols := strings.Count(header, ",") + 1 + dataCols := strings.Count(dataLine, ",") + 1 + if headerCols != dataCols { + t.Errorf("column count mismatch: header has %d, data has %d\nheader: %s\ndata: %s", + headerCols, dataCols, header, dataLine) + } +} + +func TestExtractItems(t *testing.T) { + // Standard Lark response + data := map[string]interface{}{ + "code": float64(0), + "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"id": float64(1)}, + map[string]interface{}{"id": float64(2)}, + }, + "has_more": true, + "page_token": "abc", + }, + } + + items := ExtractItems(data) + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } + + // Different array field + data2 := map[string]interface{}{ + "data": map[string]interface{}{ + "members": []interface{}{ + map[string]interface{}{"user_id": "u1"}, + }, + }, + } + + items2 := ExtractItems(data2) + if len(items2) != 1 { + t.Fatalf("expected 1 member, got %d", len(items2)) + } + + // Already an array + arr := []interface{}{"a", "b"} + items3 := ExtractItems(arr) + if len(items3) != 2 { + t.Fatalf("expected 2 items from raw array, got %d", len(items3)) + } + + // Non-response + items4 := ExtractItems("string") + if items4 != nil { + t.Fatalf("expected nil for non-response, got %v", items4) + } + + // No data field and no array field + items5 := ExtractItems(map[string]interface{}{"foo": "bar"}) + if items5 != nil { + t.Fatalf("expected nil for no data/array field, got %v", items5) + } + + // Direct map with array field (shortcut data like {"members":[…], "total":5}) + directMap := map[string]interface{}{ + "members": []interface{}{map[string]interface{}{"name": "Alice"}}, + "total": float64(1), + "has_more": false, + "page_token": "", + } + items6 := ExtractItems(directMap) + if len(items6) != 1 { + t.Fatalf("expected 1 item from direct map, got %d", len(items6)) + } + + // Direct map — plain array passed directly (e.g. calendar freebusy items) + plainArr := []interface{}{ + map[string]interface{}{"start": "10:00", "end": "11:00"}, + } + items7 := ExtractItems(plainArr) + if len(items7) != 1 { + t.Fatalf("expected 1 item from plain array, got %d", len(items7)) + } +} + +func TestFormatValue_LegacyFormats(t *testing.T) { + data := map[string]interface{}{ + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice"}, + }, + }, + } + + // "data" parses to FormatJSON with ok=false + dataFmt, dataOK := ParseFormat("data") + if dataOK { + t.Error("ParseFormat('data') should return ok=false") + } + var buf2 bytes.Buffer + FormatValue(&buf2, data, dataFmt) + out2 := buf2.String() + if !strings.Contains(out2, "items") { + t.Errorf("ParseFormat('data') → JSON should output full response, got:\n%s", out2) + } + + // unknown format parses to FormatJSON with ok=false + fooFmt, fooOK := ParseFormat("foobar") + if fooOK { + t.Error("ParseFormat('foobar') should return ok=false") + } + var buf3 bytes.Buffer + FormatValue(&buf3, data, fooFmt) + out3 := buf3.String() + if !strings.Contains(out3, "items") { + t.Errorf("ParseFormat('foobar') → JSON should output full response, got:\n%s", out3) + } +} diff --git a/internal/output/format_type.go b/internal/output/format_type.go new file mode 100644 index 00000000..c78db914 --- /dev/null +++ b/internal/output/format_type.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import "strings" + +// Format represents an output format type. +type Format int + +const ( + FormatJSON Format = iota + FormatNDJSON + FormatTable + FormatCSV +) + +// ParseFormat parses a format string into a Format value. +// The second return value is false if the format string was not recognized, +// in which case FormatJSON is returned as default. +func ParseFormat(s string) (Format, bool) { + switch strings.ToLower(s) { + case "json", "": + return FormatJSON, true + case "ndjson": + return FormatNDJSON, true + case "table": + return FormatTable, true + case "csv": + return FormatCSV, true + default: + return FormatJSON, false + } +} + +// String returns the string representation of a Format. +func (f Format) String() string { + switch f { + case FormatNDJSON: + return "ndjson" + case FormatTable: + return "table" + case FormatCSV: + return "csv" + default: + return "json" + } +} diff --git a/internal/output/format_type_test.go b/internal/output/format_type_test.go new file mode 100644 index 00000000..1c57f114 --- /dev/null +++ b/internal/output/format_type_test.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import "testing" + +func TestParseFormat(t *testing.T) { + tests := []struct { + input string + want Format + wantOK bool + }{ + {"json", FormatJSON, true}, + {"JSON", FormatJSON, true}, + {"Json", FormatJSON, true}, + {"ndjson", FormatNDJSON, true}, + {"NDJSON", FormatNDJSON, true}, + {"Ndjson", FormatNDJSON, true}, + {"table", FormatTable, true}, + {"TABLE", FormatTable, true}, + {"Table", FormatTable, true}, + {"csv", FormatCSV, true}, + {"CSV", FormatCSV, true}, + {"Csv", FormatCSV, true}, + {"", FormatJSON, true}, + // Legacy/unknown values fall back to JSON with ok=false + {"data", FormatJSON, false}, + {"raw", FormatJSON, false}, + {"RAW", FormatJSON, false}, + {"DATA", FormatJSON, false}, + {"foobar", FormatJSON, false}, + {"xml", FormatJSON, false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, ok := ParseFormat(tt.input) + if got != tt.want { + t.Errorf("ParseFormat(%q) format = %v, want %v", tt.input, got, tt.want) + } + if ok != tt.wantOK { + t.Errorf("ParseFormat(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) + } + }) + } +} + +func TestFormatString(t *testing.T) { + tests := []struct { + format Format + want string + }{ + {FormatJSON, "json"}, + {FormatNDJSON, "ndjson"}, + {FormatTable, "table"}, + {FormatCSV, "csv"}, + {Format(99), "json"}, // unknown falls back + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := tt.format.String() + if got != tt.want { + t.Errorf("Format(%d).String() = %q, want %q", tt.format, got, tt.want) + } + }) + } +} diff --git a/internal/output/lark_errors.go b/internal/output/lark_errors.go new file mode 100644 index 00000000..c5c5d10b --- /dev/null +++ b/internal/output/lark_errors.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +// Lark API generic error code constants. +// ref: https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code +const ( + // Auth: token missing / invalid / expired. + LarkErrTokenMissing = 99991661 // Authorization header missing or empty + LarkErrTokenBadFmt = 99991671 // token format error (must start with "t-" or "u-") + LarkErrTokenInvalid = 99991668 // user_access_token invalid or expired + LarkErrATInvalid = 99991663 // access_token invalid (generic) + LarkErrTokenExpired = 99991677 // user_access_token expired, refresh to obtain a new one + + // Permission: scope not granted. + LarkErrAppScopeNotEnabled = 99991672 // app has not applied for the required API scope + LarkErrTokenNoPermission = 99991676 // token lacks the required scope + LarkErrUserScopeInsufficient = 99991679 // user has not granted the required scope + LarkErrUserNotAuthorized = 230027 // user not authorized + + // App credential / status. + LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect + LarkErrAppNotInUse = 99991662 // app is disabled or not installed in this tenant + LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation + + // Rate limit. + LarkErrRateLimit = 99991400 // request frequency limit exceeded + + // Refresh token errors (authn service). + LarkErrRefreshInvalid = 20026 // refresh_token invalid or v1 format + LarkErrRefreshExpired = 20037 // refresh_token expired + LarkErrRefreshRevoked = 20064 // refresh_token revoked + LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation) + LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable +) + +// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint). +// errType provides fine-grained classification in the JSON envelope; +// exitCode is kept coarse (ExitAuth or ExitAPI). +func ClassifyLarkError(code int, msg string) (int, string, string) { + switch code { + // auth: token missing / invalid / expired + case LarkErrTokenMissing, LarkErrTokenBadFmt: + return ExitAuth, "auth", "run: lark-cli auth login to re-authorize" + case LarkErrTokenInvalid, LarkErrATInvalid, LarkErrTokenExpired: + return ExitAuth, "auth", "run: lark-cli auth login to re-authorize" + + // permission: scope not granted + case LarkErrAppScopeNotEnabled, LarkErrTokenNoPermission, + LarkErrUserScopeInsufficient, LarkErrUserNotAuthorized: + return ExitAPI, "permission", "check app permissions or re-authorize: lark-cli auth login" + + // app credential / status + case LarkErrAppCredInvalid: + return ExitAuth, "config", "check app_id / app_secret: lark-cli config set" + case LarkErrAppNotInUse, LarkErrAppUnauthorized: + return ExitAuth, "app_status", "app is disabled or not installed — check developer console" + + // rate limit + case LarkErrRateLimit: + return ExitAPI, "rate_limit", "please try again later" + } + + return ExitAPI, "api_error", "" +} diff --git a/internal/output/print.go b/internal/output/print.go new file mode 100644 index 00000000..e26e5117 --- /dev/null +++ b/internal/output/print.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/larksuite/cli/internal/validate" +) + +// PrintJson prints data as formatted JSON to w. +func PrintJson(w io.Writer, data interface{}) { + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err) + return + } + fmt.Fprintln(w, string(b)) +} + +// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w. +func PrintNdjson(w io.Writer, data interface{}) { + emit := func(item interface{}) { + b, err := json.Marshal(item) + if err != nil { + fmt.Fprintf(os.Stderr, "ndjson marshal error: %v\n", err) + return + } + fmt.Fprintln(w, string(b)) + } + if arr, ok := data.([]interface{}); ok { + for _, item := range arr { + emit(item) + } + } else { + emit(data) + } +} + +func cellStr(val interface{}) string { + if val == nil { + return "" + } + var s string + switch v := val.(type) { + case string: + s = v + case json.Number: + s = v.String() + case float64: + if v == float64(int(v)) { + s = fmt.Sprintf("%d", int(v)) + } else { + s = fmt.Sprintf("%g", v) + } + case bool: + s = fmt.Sprintf("%v", v) + default: + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + s = string(b) + } + // Sanitize for terminal display: strip ANSI escapes, control chars, dangerous Unicode. + return validate.SanitizeForTerminal(s) +} + +// PrintTable prints rows as a table to w. +// Delegates to FormatAsTable for flattening, column union, and width handling. +func PrintTable(w io.Writer, rows []map[string]interface{}) { + if len(rows) == 0 { + fmt.Fprintln(w, "(no data)") + return + } + items := make([]interface{}, len(rows)) + for i, r := range rows { + items[i] = r + } + FormatAsTable(w, items) +} + +// PrintSuccess prints a success message to w. +func PrintSuccess(w io.Writer, msg string) { + fmt.Fprintf(w, "OK: %s\n", msg) +} + +// PrintError prints an error message to w. +func PrintError(w io.Writer, msg string) { + fmt.Fprintf(w, "ERROR: %s\n", msg) +} diff --git a/internal/output/table.go b/internal/output/table.go new file mode 100644 index 00000000..017f32dc --- /dev/null +++ b/internal/output/table.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "fmt" + "io" + "strings" +) + +const maxColWidth = 100 + +// FormatAsTable formats data as a table and writes it to w. +// - []interface{} (array of objects) → header + separator + rows +// - map[string]interface{} (single object) → key-value two-column table +// - empty array → "(empty)" +func FormatAsTable(w io.Writer, data interface{}) { + FormatAsTablePaginated(w, data, true) +} + +// FormatAsTablePaginated formats data as a table with pagination awareness. +// When isFirstPage is true, outputs the header; otherwise only data rows. +func FormatAsTablePaginated(w io.Writer, data interface{}, isFirstPage bool) { + rows, cols, isList := prepareRows(data) + if cols == nil { + if isList { + fmt.Fprintln(w, "(empty)") + } else { + // Not a list and not an object — print as JSON fallback + PrintJson(w, data) + } + return + } + + if len(rows) == 0 { + if isFirstPage { + fmt.Fprintln(w, "(empty)") + } + return + } + + if !isList { + // Single object: key-value two-column format + formatKeyValueTable(w, rows[0], cols) + return + } + + // Calculate column widths (clamped to maxColWidth) + widths := computeColumnWidths(rows, cols) + + if isFirstPage { + writeHeader(w, cols, widths) + } + + for _, row := range rows { + writeRow(w, row, cols, widths) + } +} + +// formatKeyValueTable renders a single object as a two-column key-value table. +func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) { + maxKeyWidth := 0 + for _, col := range cols { + kw := stringWidth(col) + if kw > maxKeyWidth { + maxKeyWidth = kw + } + } + + for _, col := range cols { + val := row[col] + val = truncateToWidth(val, maxColWidth) + fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val) + } +} + +// computeColumnWidths returns display widths for each column, clamped to maxColWidth. +func computeColumnWidths(rows []map[string]string, cols []string) []int { + widths := make([]int, len(cols)) + for i, col := range cols { + widths[i] = stringWidth(col) + } + for _, row := range rows { + for i, col := range cols { + cw := stringWidth(row[col]) + if cw > widths[i] { + widths[i] = cw + } + } + } + // Clamp to max + for i := range widths { + if widths[i] > maxColWidth { + widths[i] = maxColWidth + } + } + return widths +} + +// writeHeader writes the header row and separator line. +func writeHeader(w io.Writer, cols []string, widths []int) { + var header []string + var sep []string + for i, col := range cols { + header = append(header, padToWidth(col, widths[i])) + sep = append(sep, strings.Repeat("─", widths[i])) + } + fmt.Fprintln(w, strings.Join(header, " ")) + fmt.Fprintln(w, strings.Join(sep, " ")) +} + +// writeRow writes a single data row. +func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) { + var cells []string + for i, col := range cols { + val := truncateToWidth(row[col], widths[i]) + cells = append(cells, padToWidth(val, widths[i])) + } + fmt.Fprintln(w, strings.Join(cells, " ")) +} + +// padToWidth pads a string with spaces to reach the target display width. +func padToWidth(s string, targetWidth int) string { + sw := stringWidth(s) + if sw >= targetWidth { + return s + } + return s + strings.Repeat(" ", targetWidth-sw) +} diff --git a/internal/output/table_test.go b/internal/output/table_test.go new file mode 100644 index 00000000..4ecca7d1 --- /dev/null +++ b/internal/output/table_test.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestFormatAsTable_ObjectArray(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"name": "Alice", "age": float64(30)}, + map[string]interface{}{"name": "Bob", "age": float64(25)}, + } + + var buf bytes.Buffer + FormatAsTable(&buf, data) + out := buf.String() + + if !strings.Contains(out, "name") { + t.Errorf("output should contain 'name' header, got:\n%s", out) + } + if !strings.Contains(out, "age") { + t.Errorf("output should contain 'age' header, got:\n%s", out) + } + if !strings.Contains(out, "Alice") { + t.Errorf("output should contain 'Alice', got:\n%s", out) + } + if !strings.Contains(out, "Bob") { + t.Errorf("output should contain 'Bob', got:\n%s", out) + } + // Should contain separator with ─ + if !strings.Contains(out, "─") { + t.Errorf("output should contain ─ separator, got:\n%s", out) + } +} + +func TestFormatAsTable_SingleObject(t *testing.T) { + data := map[string]interface{}{ + "name": "Alice", + "age": float64(30), + } + + var buf bytes.Buffer + FormatAsTable(&buf, data) + out := buf.String() + + if !strings.Contains(out, "name") { + t.Errorf("output should contain 'name', got:\n%s", out) + } + if !strings.Contains(out, "Alice") { + t.Errorf("output should contain 'Alice', got:\n%s", out) + } +} + +func TestFormatAsTable_EmptyArray(t *testing.T) { + data := []interface{}{} + + var buf bytes.Buffer + FormatAsTable(&buf, data) + out := strings.TrimSpace(buf.String()) + + if out != "(empty)" { + t.Errorf("empty array should output '(empty)', got:\n%s", out) + } +} + +func TestFormatAsTable_NestedFlattening(t *testing.T) { + data := []interface{}{ + map[string]interface{}{ + "user": map[string]interface{}{ + "name": "Alice", + }, + "id": float64(1), + }, + } + + var buf bytes.Buffer + FormatAsTable(&buf, data) + out := buf.String() + + if !strings.Contains(out, "user.name") { + t.Errorf("output should contain flattened 'user.name' column, got:\n%s", out) + } + if !strings.Contains(out, "Alice") { + t.Errorf("output should contain 'Alice', got:\n%s", out) + } +} + +func TestFormatAsTable_ColumnUnionFromAllRows(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"a": "1"}, + map[string]interface{}{"a": "2", "b": "3"}, + } + + var buf bytes.Buffer + FormatAsTable(&buf, data) + out := buf.String() + + if !strings.Contains(out, "b") { + t.Errorf("output should contain column 'b' from second row, got:\n%s", out) + } +} + +func TestFormatAsTablePaginated_FirstPage(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"name": "Alice"}, + } + + var buf bytes.Buffer + FormatAsTablePaginated(&buf, data, true) + out := buf.String() + + // First page should have header + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) < 3 { + t.Errorf("first page should have header + separator + data, got %d lines:\n%s", len(lines), out) + } +} + +func TestFormatAsTablePaginated_ContinuationPage(t *testing.T) { + data := []interface{}{ + map[string]interface{}{"name": "Bob"}, + } + + var buf bytes.Buffer + FormatAsTablePaginated(&buf, data, false) + out := buf.String() + + // Continuation page should not have header/separator + if strings.Contains(out, "─") { + t.Errorf("continuation page should not contain separator, got:\n%s", out) + } + if !strings.Contains(out, "Bob") { + t.Errorf("continuation page should contain data, got:\n%s", out) + } + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) != 1 { + t.Errorf("continuation page should have 1 data line, got %d lines:\n%s", len(lines), out) + } +} + +func TestFormatAsTable_ColumnWidthClamp(t *testing.T) { + // Create a value longer than maxColWidth + longVal := strings.Repeat("x", 101) + data := []interface{}{ + map[string]interface{}{"col": longVal}, + } + + var buf bytes.Buffer + FormatAsTable(&buf, data) + out := buf.String() + + if strings.Contains(out, longVal) { + t.Errorf("output should not contain the full long value (should be truncated)") + } + if !strings.Contains(out, "…") { + t.Errorf("output should contain truncation marker …, got:\n%s", out) + } +} diff --git a/internal/registry/helpers.go b/internal/registry/helpers.go new file mode 100644 index 00000000..6f564adf --- /dev/null +++ b/internal/registry/helpers.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +// GetStrFromMap extracts a string value from map[string]interface{}. +func GetStrFromMap(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// GetStrSliceFromMap extracts a []string value from map[string]interface{}. +// Returns nil if the key is missing or the value is not a string slice. +func GetStrSliceFromMap(m map[string]interface{}, key string) []string { + if m == nil { + return nil + } + raw, ok := m[key].([]interface{}) + if !ok { + return nil + } + result := make([]string, 0, len(raw)) + for _, v := range raw { + if s, ok := v.(string); ok { + result = append(result, s) + } + } + if len(result) == 0 { + return nil + } + return result +} + +// SelectRecommendedScope selects the known scope with the highest priority score +// (higher = more recommended / least privilege). +// Scopes not in the priority table are skipped to avoid recommending invalid/unknown scopes. +func SelectRecommendedScope(scopes []interface{}, identity string) string { + priorities := LoadScopePriorities() + bestScore := -1 + bestScope := "" + for _, s := range scopes { + str, ok := s.(string) + if !ok { + continue + } + score, exists := priorities[str] + if !exists { + continue // skip unknown scopes + } + if score > bestScore { + bestScore = score + bestScope = str + } + } + if bestScope != "" { + return bestScope + } + // Fallback: if no scope is in the priority table, return the first one. + if len(scopes) > 0 { + if s, ok := scopes[0].(string); ok { + return s + } + } + return "" +} diff --git a/internal/registry/loader.go b/internal/registry/loader.go new file mode 100644 index 00000000..a310326d --- /dev/null +++ b/internal/registry/loader.go @@ -0,0 +1,385 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "embed" + "encoding/json" + "math" + "path/filepath" + "runtime" + "sort" + "strconv" + "sync" + + "github.com/larksuite/cli/internal/core" +) + +//go:embed scope_priorities.json scope_overrides.json +var registryFS embed.FS + +// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in. +var embeddedMetaJSON []byte + +var ( + mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec + mergedProjectList []string // sorted project names + embeddedVersion string // version from embedded meta_data.json + initOnce sync.Once +) + +// Init initializes the registry with default brand (feishu). +// It is safe to call multiple times (sync.Once). +func Init() { + InitWithBrand(core.BrandFeishu) +} + +// InitWithBrand initializes the registry by loading embedded data and optionally +// overlaying cached remote data. The brand determines which remote API host to use. +// It is safe to call multiple times (sync.Once). +// Remote fetch errors are silently ignored when embedded data is available. +// If no embedded data exists and no cache is found, a synchronous fetch is attempted. +func InitWithBrand(brand core.LarkBrand) { + initOnce.Do(func() { + configuredBrand = brand + // 1. Load embedded meta_data.json as baseline (no-op if not compiled in) + loadEmbeddedIntoMerged() + // 2. Remote overlay + if remoteEnabled() && cacheWritable() { + // Check if brand changed since last cache + meta, metaErr := loadCacheMeta() + brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand) + + if !brandChanged { + if cached, err := loadCachedMerged(); err == nil { + overlayMergedServices(cached) + } + } + if len(mergedServices) == 0 || brandChanged { + // No data at all or brand changed — must sync fetch + doSyncFetch() + } else if shouldRefresh(meta) || metaErr != nil { + // Have embedded/cached data; refresh in background if TTL expired or first run + triggerBackgroundRefresh() + } + } + // 3. Build sorted project list + rebuildProjectList() + }) +} + +// loadEmbeddedIntoMerged parses the embedded meta_data.json and populates +// mergedServices. No-op if meta_data.json is not compiled in. +func loadEmbeddedIntoMerged() { + if len(embeddedMetaJSON) == 0 { + return + } + var reg MergedRegistry + if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil { + return + } + embeddedVersion = reg.Version + overlayMergedServices(®) +} + +// rebuildProjectList rebuilds the sorted list of project names from mergedServices. +func rebuildProjectList() { + mergedProjectList = make([]string, 0, len(mergedServices)) + for name := range mergedServices { + mergedProjectList = append(mergedProjectList, name) + } + sort.Strings(mergedProjectList) +} + +var cachedAllScopes map[string][]string + +// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json +// for the given identity ("user" or "tenant"). Results are deduplicated and sorted. +func CollectAllScopesFromMeta(identity string) []string { + if cachedAllScopes == nil { + cachedAllScopes = make(map[string][]string) + } + if cached, ok := cachedAllScopes[identity]; ok { + return cached + } + + scopeSet := make(map[string]bool) + for _, project := range ListFromMetaProjects() { + spec := LoadFromMeta(project) + if spec == nil { + continue + } + resources, ok := spec["resources"].(map[string]interface{}) + if !ok { + continue + } + for _, resSpec := range resources { + resMap, ok := resSpec.(map[string]interface{}) + if !ok { + continue + } + methods, ok := resMap["methods"].(map[string]interface{}) + if !ok { + continue + } + for _, methodSpec := range methods { + methodMap, ok := methodSpec.(map[string]interface{}) + if !ok { + continue + } + // Check if method supports the requested identity + if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { + supported := false + for _, t := range tokens { + if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { + supported = true + break + } + } + if !supported { + continue + } + } + // Collect scopes + scopes, ok := methodMap["scopes"].([]interface{}) + if !ok { + continue + } + for _, s := range scopes { + if str, ok := s.(string); ok { + scopeSet[str] = true + } + } + } + } + } + + result := make([]string, 0, len(scopeSet)) + for s := range scopeSet { + result = append(result, s) + } + sort.Strings(result) + cachedAllScopes[identity] = result + return result +} + +// LoadFromMeta loads a service schema by project name. +// It returns data from the merged registry (embedded + cached remote overlay). +func LoadFromMeta(project string) map[string]interface{} { + Init() + return mergedServices[project] +} + +// ListFromMetaProjects lists available service project names (sorted). +// +//go:noinline +func ListFromMetaProjects() []string { + Init() + return mergedProjectList +} + +// DefaultScopeScore is the score assigned to scopes not in the priorities table. +// Higher score = more recommended. Unscored scopes get 0 (least preferred). +const DefaultScopeScore = 0 + +var cachedScopePriorities map[string]int +var cachedAutoApproveSet map[string]bool +var cachedPlatformAutoApprove map[string]bool // from scope_priorities.json only +var cachedOverrideAutoAllow map[string]bool // from scope_overrides.json allow only +var cachedOverrideAutoDeny map[string]bool // from scope_overrides.json deny only + +// scopePriorityEntry is used to parse scope_priorities.json entries. +type scopePriorityEntry struct { + ScopeName string `json:"scope_name"` + FinalScore string `json:"final_score"` + Recommend string `json:"recommend"` +} + +// LoadScopePriorities loads the scope priorities map from scope_priorities.json. +// Scores are stored as float strings (e.g. "52.42") and rounded to int. +func LoadScopePriorities() map[string]int { + if cachedScopePriorities != nil { + return cachedScopePriorities + } + + data, err := registryFS.ReadFile("scope_priorities.json") + if err != nil { + cachedScopePriorities = make(map[string]int) + return cachedScopePriorities + } + + var entries []scopePriorityEntry + if err := json.Unmarshal(data, &entries); err != nil { + cachedScopePriorities = make(map[string]int) + return cachedScopePriorities + } + + m := make(map[string]int, len(entries)) + for _, entry := range entries { + f, err := strconv.ParseFloat(entry.FinalScore, 64) + if err != nil { + continue + } + m[entry.ScopeName] = int(math.Round(f)) + } + + // Apply manual overrides from scope_overrides.json + if overrideData, err := registryFS.ReadFile("scope_overrides.json"); err == nil { + var wrapper struct { + PriorityOverrides map[string]int `json:"priority_overrides"` + } + if json.Unmarshal(overrideData, &wrapper) == nil { + for scope, score := range wrapper.PriorityOverrides { + m[scope] = score + } + } + } + + cachedScopePriorities = m + return cachedScopePriorities +} + +// LoadAutoApproveSet returns the set of auto-approve scope names. +// Sources (merged): recommend=="true" in scope_priorities.json +// + explicit allow/deny in scope_overrides.json. +func LoadAutoApproveSet() map[string]bool { + if cachedAutoApproveSet != nil { + return cachedAutoApproveSet + } + + m := make(map[string]bool) + + // 1. From scope_priorities.json (Recommend == "true") + if data, err := registryFS.ReadFile("scope_priorities.json"); err == nil { + var entries []scopePriorityEntry + if json.Unmarshal(data, &entries) == nil { + for _, entry := range entries { + if entry.Recommend == "true" { + m[entry.ScopeName] = true + } + } + } + } + + // 2. From scope_overrides.json (recommend.allow/deny lists) + if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil { + var wrapper struct { + AutoApprove struct { + Allow []string `json:"allow"` + Deny []string `json:"deny"` + } `json:"recommend"` + } + if json.Unmarshal(data, &wrapper) == nil { + for _, s := range wrapper.AutoApprove.Allow { + m[s] = true + } + for _, s := range wrapper.AutoApprove.Deny { + delete(m, s) + } + } + } + + cachedAutoApproveSet = m + return cachedAutoApproveSet +} + +// LoadPlatformAutoApproveSet returns scopes with AutoApprove rule on the platform +// (from scope_priorities.json only, before overrides). +func LoadPlatformAutoApproveSet() map[string]bool { + if cachedPlatformAutoApprove != nil { + return cachedPlatformAutoApprove + } + m := make(map[string]bool) + if data, err := registryFS.ReadFile("scope_priorities.json"); err == nil { + var entries []scopePriorityEntry + if json.Unmarshal(data, &entries) == nil { + for _, entry := range entries { + if entry.Recommend == "true" { + m[entry.ScopeName] = true + } + } + } + } + cachedPlatformAutoApprove = m + return cachedPlatformAutoApprove +} + +// LoadOverrideAutoApproveAllow returns scopes explicitly listed in +// scope_overrides.json recommend.allow (our desired additions). +func LoadOverrideAutoApproveAllow() map[string]bool { + if cachedOverrideAutoAllow != nil { + return cachedOverrideAutoAllow + } + m := make(map[string]bool) + if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil { + var wrapper struct { + AutoApprove struct { + Allow []string `json:"allow"` + } `json:"recommend"` + } + if json.Unmarshal(data, &wrapper) == nil { + for _, s := range wrapper.AutoApprove.Allow { + m[s] = true + } + } + } + cachedOverrideAutoAllow = m + return cachedOverrideAutoAllow +} + +// LoadOverrideAutoApproveDeny returns scopes explicitly listed in +// scope_overrides.json recommend.deny +func LoadOverrideAutoApproveDeny() map[string]bool { + if cachedOverrideAutoDeny != nil { + return cachedOverrideAutoDeny + } + m := make(map[string]bool) + if data, err := registryFS.ReadFile("scope_overrides.json"); err == nil { + var wrapper struct { + AutoApprove struct { + Deny []string `json:"deny"` + } `json:"recommend"` + } + if json.Unmarshal(data, &wrapper) == nil { + for _, s := range wrapper.AutoApprove.Deny { + m[s] = true + } + } + } + cachedOverrideAutoDeny = m + return cachedOverrideAutoDeny +} + +// IsAutoApproveScope returns true if the scope has AutoApprove rule. +func IsAutoApproveScope(scope string) bool { + return LoadAutoApproveSet()[scope] +} + +// FilterAutoApproveScopes filters a scope list to only include auto-approve scopes. +func FilterAutoApproveScopes(scopes []string) []string { + autoApprove := LoadAutoApproveSet() + var result []string + for _, s := range scopes { + if autoApprove[s] { + result = append(result, s) + } + } + return result +} + +// GetScopeScore returns the priority score for a scope, or DefaultScopeScore if not found. +func GetScopeScore(scope string) int { + priorities := LoadScopePriorities() + if score, ok := priorities[scope]; ok { + return score + } + return DefaultScopeScore +} + +// GetRegistryDir returns the filesystem path to the registry directory. +// Used for finding skills files etc. +func GetRegistryDir() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Dir(filename) +} diff --git a/internal/registry/loader_embedded.go b/internal/registry/loader_embedded.go new file mode 100644 index 00000000..da41e079 --- /dev/null +++ b/internal/registry/loader_embedded.go @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import "embed" + +//go:embed meta_data*.json +var metaFS embed.FS + +//go:embed meta_data_default.json +var embeddedMetaDataDefaultJSON []byte + +func init() { + if data, err := metaFS.ReadFile("meta_data.json"); err == nil && len(data) > 0 { + embeddedMetaJSON = data + } else { + embeddedMetaJSON = embeddedMetaDataDefaultJSON + } +} diff --git a/internal/registry/meta_data_default.json b/internal/registry/meta_data_default.json new file mode 100644 index 00000000..a070ff22 --- /dev/null +++ b/internal/registry/meta_data_default.json @@ -0,0 +1 @@ +{"version":"0.0.0","services":[]} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 00000000..1ec7660f --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,557 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "sort" + "strings" + "testing" +) + +func TestLoadScopePriorities(t *testing.T) { + priorities := LoadScopePriorities() + if len(priorities) == 0 { + t.Fatal("expected non-empty priorities map") + } + t.Logf("Loaded %d scope priorities", len(priorities)) + + // Verify a known scope exists (im:message:recall is in the user's data) + if _, ok := priorities["im:message:recall"]; !ok { + t.Error("expected im:message:recall in priorities") + } +} + +func TestGetScopeScore(t *testing.T) { + // Known scope should have a real score + score := GetScopeScore("im:message:recall") + if score == DefaultScopeScore { + t.Errorf("expected real score for im:message:recall, got default %d", score) + } + t.Logf("im:message:recall score: %d", score) + + // Unknown scope should return default + score = GetScopeScore("unknown:scope:here") + if score != DefaultScopeScore { + t.Errorf("expected %d, got %d", DefaultScopeScore, score) + } + + // Override: im:chat:readonly should be overridden to 1 + score = GetScopeScore("im:chat:readonly") + if score != 1 { + t.Errorf("expected im:chat:readonly override score 1, got %d", score) + } +} + +func TestSelectRecommendedScope_PicksHighestScore(t *testing.T) { + priorities := LoadScopePriorities() + + // Find two scopes with known different scores + scopeA := "calendar:calendar:readonly" + scopeB := "calendar:calendar" + + scoreA, okA := priorities[scopeA] + scoreB, okB := priorities[scopeB] + if !okA || !okB { + t.Skipf("test scopes not in priorities (A=%v, B=%v)", okA, okB) + } + t.Logf("%s=%d, %s=%d", scopeA, scoreA, scopeB, scoreB) + + scopes := []interface{}{scopeB, scopeA} + result := SelectRecommendedScope(scopes, "user") + + // Should pick the higher-scored one (higher = more recommended) + if scoreA > scoreB { + if result != scopeA { + t.Errorf("expected %s (score %d), got %s", scopeA, scoreA, result) + } + } else { + if result != scopeB { + t.Errorf("expected %s (score %d), got %s", scopeB, scoreB, result) + } + } +} + +func TestSelectRecommendedScope_FallbackToFirst(t *testing.T) { + scopes := []interface{}{ + "zzz_unknown:scope:a", + "zzz_unknown:scope:b", + } + result := SelectRecommendedScope(scopes, "user") + // All unknown scopes get DefaultScopeScore; first one with that score wins + if result != "zzz_unknown:scope:a" { + t.Errorf("expected zzz_unknown:scope:a, got %s", result) + } +} + +func TestSelectRecommendedScope_Empty(t *testing.T) { + result := SelectRecommendedScope(nil, "user") + if result != "" { + t.Errorf("expected empty string, got %s", result) + } + + result = SelectRecommendedScope([]interface{}{}, "user") + if result != "" { + t.Errorf("expected empty string, got %s", result) + } +} + +func TestComputeMinimumScopeSet(t *testing.T) { + minSet := ComputeMinimumScopeSet("user") + if len(minSet) == 0 { + if len(ListFromMetaProjects()) == 0 { + t.Skip("no from_meta data available") + } + t.Fatal("expected non-empty minimum scope set") + } + + // Verify sorted + if !sort.StringsAreSorted(minSet) { + t.Error("expected sorted result") + } + + // Verify no duplicates + seen := make(map[string]bool) + for _, s := range minSet { + if seen[s] { + t.Errorf("duplicate scope: %s", s) + } + seen[s] = true + } + + t.Logf("Minimum scope set (%d scopes): %v", len(minSet), minSet) +} + +func TestComputeMinimumScopeSet_Tenant(t *testing.T) { + minSet := ComputeMinimumScopeSet("tenant") + if len(minSet) == 0 { + if len(ListFromMetaProjects()) == 0 { + t.Skip("no from_meta data available") + } + t.Fatal("expected non-empty minimum scope set for tenant") + } + t.Logf("Tenant minimum scope set (%d scopes): %v", len(minSet), minSet) +} + +func TestFilterScopes(t *testing.T) { + scopes := []string{ + "calendar:calendar.event:read", + "calendar:calendar:readonly", + "task:task:read", + "drive:drive.metadata:readonly", + } + + // Filter by domain + result := FilterScopes(scopes, []string{"calendar"}, nil) + if len(result) != 2 { + t.Errorf("expected 2 calendar scopes, got %d: %v", len(result), result) + } + + // Filter by permission + result = FilterScopes(scopes, nil, []string{"read"}) + for _, s := range result { + t.Logf("read-filtered: %s", s) + } +} + +func TestFilterScopes_WritePermission(t *testing.T) { + scopes := []string{ + "calendar:calendar.event:read", + "calendar:calendar:readonly", + "task:task:write", + "drive:drive:writeonly", + "drive:drive:write_only", + } + + result := FilterScopes(scopes, nil, []string{"write"}) + // "write" matches anything containing "write" (including writeonly, write_only) + if len(result) != 3 { + t.Errorf("expected 3 scopes matching 'write', got %d: %v", len(result), result) + } + + result = FilterScopes(scopes, nil, []string{"writeonly"}) + if len(result) != 2 { + t.Errorf("expected 2 writeonly scopes, got %d: %v", len(result), result) + } +} + +func TestFilterScopes_DomainAndPermission(t *testing.T) { + scopes := []string{ + "calendar:calendar.event:read", + "calendar:calendar:readonly", + "task:task:read", + "drive:drive.metadata:readonly", + } + + // Filter by domain AND permission + result := FilterScopes(scopes, []string{"calendar"}, []string{"readonly"}) + if len(result) != 1 || result[0] != "calendar:calendar:readonly" { + t.Errorf("expected [calendar:calendar:readonly], got %v", result) + } +} + +func TestFilterScopes_NilFilters(t *testing.T) { + scopes := []string{"a:b:c", "d:e:f"} + result := FilterScopes(scopes, nil, nil) + if len(result) != 2 { + t.Errorf("expected all scopes returned when no filters, got %d", len(result)) + } +} + +func TestFilterScopes_Empty(t *testing.T) { + result := FilterScopes(nil, nil, nil) + if result != nil { + t.Errorf("expected nil, got %v", result) + } +} + +func TestFilterScopes_TooFewParts(t *testing.T) { + scopes := []string{"onlyonepart", "two:parts"} + // Permission filter requires at least 3 parts + result := FilterScopes(scopes, nil, []string{"read"}) + if len(result) != 0 { + t.Errorf("expected 0 results for short scopes, got %v", result) + } +} + +// --- Auto-approve functions --- + +func TestLoadAutoApproveSet(t *testing.T) { + aaSet := LoadAutoApproveSet() + if len(aaSet) == 0 { + t.Fatal("expected non-empty auto-approve set") + } + + // From scope_overrides.json allow list + if !aaSet["calendar:calendar.event:create"] { + t.Error("expected calendar:calendar.event:create in auto-approve set (from allow list)") + } + + // Verify allow list entries are present + if !aaSet["sheets:spreadsheet:read"] { + t.Error("expected sheets:spreadsheet:read in auto-approve set (from allow list)") + } + + t.Logf("Auto-approve set has %d scopes", len(aaSet)) +} + +func TestLoadPlatformAutoApproveSet(t *testing.T) { + paaSet := LoadPlatformAutoApproveSet() + // This should only include scopes from scope_priorities.json with AutoApprove rule. + // It does NOT apply deny overrides. + if len(paaSet) == 0 { + t.Fatal("expected non-empty platform auto-approve set") + } + + t.Logf("Platform auto-approve set has %d scopes", len(paaSet)) +} + +func TestLoadOverrideAutoApproveAllow(t *testing.T) { + allowSet := LoadOverrideAutoApproveAllow() + if len(allowSet) == 0 { + t.Fatal("expected non-empty override allow set") + } + + // Known entries from scope_overrides.json + if !allowSet["calendar:calendar.event:create"] { + t.Error("expected calendar:calendar.event:create in allow set") + } + if !allowSet["mail:event"] { + t.Error("expected mail:event in allow set") + } +} + +func TestLoadOverrideAutoApproveDeny(t *testing.T) { + denySet := LoadOverrideAutoApproveDeny() + // deny list may be empty if all entries are moved to _deny (commented out) + t.Logf("Override deny set has %d scopes", len(denySet)) +} + +func TestIsAutoApproveScope(t *testing.T) { + // Known auto-approve scope (in allow list) + if !IsAutoApproveScope("calendar:calendar.event:create") { + t.Error("expected calendar:calendar.event:create to be auto-approve") + } + + // Completely unknown scope + if IsAutoApproveScope("zzz:unknown:scope") { + t.Error("expected unknown scope to NOT be auto-approve") + } +} + +func TestFilterAutoApproveScopes(t *testing.T) { + scopes := []string{ + "calendar:calendar.event:create", // auto-approve (in allow list) + "zzz:unknown:scope", // not in auto-approve + "sheets:spreadsheet:read", // auto-approve (in allow list) + } + + result := FilterAutoApproveScopes(scopes) + if len(result) < 1 { + t.Fatal("expected at least 1 auto-approve scope in result") + } + + // Check that calendar:calendar.event:create is included + found := false + for _, s := range result { + if s == "calendar:calendar.event:create" { + found = true + } + // Ensure unknown scopes are not included + if s == "zzz:unknown:scope" { + t.Error("unknown scope should not be in auto-approve result") + } + } + if !found { + t.Error("expected calendar:calendar.event:create in result") + } +} + +func TestFilterAutoApproveScopes_Empty(t *testing.T) { + result := FilterAutoApproveScopes(nil) + if result != nil { + t.Errorf("expected nil, got %v", result) + } + + result = FilterAutoApproveScopes([]string{}) + if result != nil { + t.Errorf("expected nil for empty input, got %v", result) + } +} + +// --- Helper functions --- + +func TestGetStrFromMap(t *testing.T) { + m := map[string]interface{}{ + "key1": "value1", + "key2": 42, + "key3": nil, + } + + if v := GetStrFromMap(m, "key1"); v != "value1" { + t.Errorf("expected value1, got %s", v) + } + if v := GetStrFromMap(m, "key2"); v != "" { + t.Errorf("expected empty for non-string value, got %s", v) + } + if v := GetStrFromMap(m, "missing"); v != "" { + t.Errorf("expected empty for missing key, got %s", v) + } + if v := GetStrFromMap(nil, "key"); v != "" { + t.Errorf("expected empty for nil map, got %s", v) + } +} + +func TestGetRegistryDir(t *testing.T) { + dir := GetRegistryDir() + if dir == "" { + t.Error("expected non-empty registry dir") + } + t.Logf("Registry dir: %s", dir) +} + +// --- Scope collection functions --- + +func TestCollectAllScopesFromMeta(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + allScopes := CollectAllScopesFromMeta("user") + if len(allScopes) == 0 { + t.Fatal("expected non-empty scopes from from_meta") + } + + // Should be sorted + if !sort.StringsAreSorted(allScopes) { + t.Error("expected sorted result") + } + + // Should include more scopes than the minimum set (since minimum picks best per method) + minSet := ComputeMinimumScopeSet("user") + if len(allScopes) < len(minSet) { + t.Errorf("all scopes (%d) should be >= minimum set (%d)", len(allScopes), len(minSet)) + } + + t.Logf("All scopes from meta: %d (min set: %d)", len(allScopes), len(minSet)) +} + +func TestCollectAllScopesFromMeta_Caching(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + result1 := CollectAllScopesFromMeta("user") + result2 := CollectAllScopesFromMeta("user") + + if len(result1) != len(result2) { + t.Errorf("cached result length mismatch: %d vs %d", len(result1), len(result2)) + } +} + +func TestCollectScopesWithSources(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + // Use calendar project which is well-known + scopes, sources := CollectScopesWithSources([]string{"calendar"}, "user") + if len(scopes) == 0 { + t.Fatal("expected non-empty scopes for calendar") + } + + // Should be sorted + if !sort.StringsAreSorted(scopes) { + t.Error("expected sorted scopes") + } + + // Each scope should have a source + for _, s := range scopes { + src, ok := sources[s] + if !ok { + t.Errorf("scope %s has no source entry", s) + continue + } + if len(src.APIs) == 0 { + t.Errorf("scope %s has no API sources", s) + } + } + + t.Logf("Calendar scopes with sources: %d scopes", len(scopes)) +} + +func TestCollectScopesWithSources_EmptyProject(t *testing.T) { + scopes, sources := CollectScopesWithSources([]string{"nonexistent_project"}, "user") + if len(scopes) != 0 { + t.Errorf("expected empty scopes for nonexistent project, got %d", len(scopes)) + } + if len(sources) != 0 { + t.Errorf("expected empty sources for nonexistent project, got %d", len(sources)) + } +} + +func TestCollectCommandScopes(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + entries := CollectCommandScopes([]string{"calendar"}, "user") + if len(entries) == 0 { + t.Fatal("expected non-empty command entries for calendar") + } + + // Verify sorted by Command + for i := 1; i < len(entries); i++ { + if entries[i].Command < entries[i-1].Command { + t.Errorf("entries not sorted: %s < %s", entries[i].Command, entries[i-1].Command) + } + } + + // Verify each entry has scopes and type + for _, e := range entries { + if e.Command == "" { + t.Error("entry has empty command") + } + if e.Type != "api" { + t.Errorf("expected type 'api', got %q", e.Type) + } + if len(e.Scopes) == 0 { + t.Errorf("entry %s has no scopes", e.Command) + } + } + + t.Logf("Calendar command entries: %d", len(entries)) +} + +func TestCollectCommandScopes_EmptyProject(t *testing.T) { + entries := CollectCommandScopes([]string{"nonexistent_project"}, "user") + if len(entries) != 0 { + t.Errorf("expected empty entries for nonexistent project, got %d", len(entries)) + } +} + +func TestGetScopesForDomains(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + // GetScopesForDomains is a wrapper for CollectScopesForProjects + scopes := GetScopesForDomains([]string{"calendar"}, "user") + expected := CollectScopesForProjects([]string{"calendar"}, "user") + + if len(scopes) != len(expected) { + t.Errorf("GetScopesForDomains and CollectScopesForProjects differ: %d vs %d", len(scopes), len(expected)) + } +} + +func TestGetReadOnlyScopes(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + readOnly := GetReadOnlyScopes("user") + // May be empty if no read-only scopes exist, but should not panic + for _, s := range readOnly { + parts := strings.Split(s, ":") + if len(parts) < 3 { + t.Errorf("unexpected scope format (too few parts): %s", s) + continue + } + perm := parts[2] + if !strings.Contains(perm, "read") && perm != "readonly" { + t.Errorf("non-read scope in read-only result: %s", s) + } + } + + t.Logf("Read-only scopes: %d", len(readOnly)) +} + +func TestResolveScopesFromFilters(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Skip("no from_meta data available") + } + + // Should behave like CollectScopesForProjects + FilterScopes + scopes := ResolveScopesFromFilters([]string{"calendar"}, []string{"read", "readonly"}, "user") + for _, s := range scopes { + parts := strings.Split(s, ":") + if len(parts) < 3 { + continue + } + perm := parts[2] + if !strings.Contains(perm, "read") && perm != "readonly" { + t.Errorf("non-read scope in filtered result: %s", s) + } + } + + t.Logf("Resolved filtered scopes: %d", len(scopes)) +} + +func TestCollectScopesForProjects_MultipleProjects(t *testing.T) { + projects := ListFromMetaProjects() + if len(projects) < 2 { + t.Skip("need at least 2 from_meta projects") + } + + // Multiple projects should yield more scopes than a single one + single := CollectScopesForProjects(projects[:1], "user") + multi := CollectScopesForProjects(projects[:2], "user") + + if len(multi) < len(single) { + t.Errorf("multi-project scopes (%d) should be >= single-project (%d)", len(multi), len(single)) + } +} + +func TestCollectScopesForProjects_NonexistentProject(t *testing.T) { + scopes := CollectScopesForProjects([]string{"nonexistent_project_xyz"}, "user") + if len(scopes) != 0 { + t.Errorf("expected empty scopes for nonexistent project, got %d", len(scopes)) + } +} diff --git a/internal/registry/remote.go b/internal/registry/remote.go new file mode 100644 index 00000000..135c1f43 --- /dev/null +++ b/internal/registry/remote.go @@ -0,0 +1,311 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" +) + +const ( + defaultMetaTTL = 86400 // seconds (24h) + maxResponseSize = 10 << 20 // 10 MB + fetchTimeout = 5 * time.Second +) + +// CacheMeta holds metadata about the cached remote_meta.json file. +type CacheMeta struct { + LastCheckAt int64 `json:"last_check_at"` + Version string `json:"version,omitempty"` + Brand string `json:"brand,omitempty"` +} + +// MergedRegistry is the top-level structure of remote_meta.json. +type MergedRegistry struct { + Version string `json:"version"` + Services []map[string]interface{} `json:"services"` +} + +// remoteResponse is the envelope returned by the remote API. +type remoteResponse struct { + Msg string `json:"msg"` + Data MergedRegistry `json:"data"` +} + +// configuredBrand is set by InitWithBrand and determines which API host to use. +var configuredBrand core.LarkBrand + +// --- configuration helpers --- + +// enableRemoteMeta controls whether remote API meta fetching is active. +// Flip to true when ready to roll out. +var enableRemoteMeta = true + +func remoteEnabled() bool { + if !enableRemoteMeta { + return false + } + return os.Getenv("LARKSUITE_CLI_REMOTE_META") != "off" +} + +// testMetaURL overrides the remote meta URL for testing. +var testMetaURL string + +func remoteMetaURL(version string) string { + if testMetaURL != "" { + return testMetaURL + } + var base string + switch configuredBrand { + case core.BrandLark: + base = "https://open.larksuite.com/api/tools/open/api_definition" + default: + base = "https://open.feishu.cn/api/tools/open/api_definition" + } + q := "protocol=meta&client_version=" + url.QueryEscape(build.Version) + if version != "" { + q += "&data_version=" + url.QueryEscape(version) + } + return base + "?" + q +} + +func metaTTL() time.Duration { + if s := os.Getenv("LARKSUITE_CLI_META_TTL"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n >= 0 { + return time.Duration(n) * time.Second + } + } + return defaultMetaTTL * time.Second +} + +// --- cache path helpers --- + +func cacheDir() string { + return filepath.Join(core.GetConfigDir(), "cache") +} + +func cachePath() string { + return filepath.Join(cacheDir(), "remote_meta.json") +} + +func cacheMetaPath() string { + return filepath.Join(cacheDir(), "remote_meta.meta.json") +} + +// cacheWritable checks if the cache directory is writable. +// Returns false if the directory cannot be created or written to. +func cacheWritable() bool { + dir := cacheDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return false + } + probe := filepath.Join(dir, ".probe") + if err := os.WriteFile(probe, []byte{}, 0644); err != nil { + return false + } + os.Remove(probe) + return true +} + +// --- cache I/O --- + +func loadCacheMeta() (CacheMeta, error) { + var meta CacheMeta + data, err := os.ReadFile(cacheMetaPath()) + if err != nil { + return meta, err + } + if err = json.Unmarshal(data, &meta); err != nil { + return meta, err + } + return meta, nil +} + +func saveCacheMeta(meta CacheMeta) error { + if err := os.MkdirAll(cacheDir(), 0700); err != nil { + return err + } + data, err := json.Marshal(meta) + if err != nil { + return err + } + return validate.AtomicWrite(cacheMetaPath(), data, 0644) +} + +func loadCachedMerged() (*MergedRegistry, error) { + path := cachePath() + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var reg MergedRegistry + if err := json.Unmarshal(data, ®); err != nil { + // Cache corrupted — remove it so next run triggers a fresh fetch + os.Remove(path) + os.Remove(cacheMetaPath()) + return nil, err + } + return ®, nil +} + +func saveCachedMerged(data []byte, meta CacheMeta) error { + if err := os.MkdirAll(cacheDir(), 0700); err != nil { + return err + } + if err := validate.AtomicWrite(cachePath(), data, 0644); err != nil { + return err + } + return saveCacheMeta(meta) +} + +// --- HTTP fetch --- + +// fetchRemoteMerged fetches the remote API definition. +// localVersion is sent as data_version query param for server-side version comparison. +// Returns (data, reg, err). A nil reg means the version is unchanged (not modified). +func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) { + client := &http.Client{Timeout: fetchTimeout} + req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil) + if err != nil { + return nil, nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, nil, &httpError{StatusCode: resp.StatusCode} + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize)) + if err != nil { + return nil, nil, err + } + + // Parse the envelope response + var envelope remoteResponse + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, nil, err + } + if envelope.Msg != "succeeded" { + return nil, nil, fmt.Errorf("remote meta: unexpected msg %q", envelope.Msg) + } + + // If data.Services is nil, the version is up-to-date (not modified) + if envelope.Data.Services == nil { + return nil, nil, nil + } + + // Re-marshal just the data portion for caching + dataBytes, err := json.Marshal(envelope.Data) + if err != nil { + return nil, nil, err + } + + return dataBytes, &envelope.Data, nil +} + +type httpError struct { + StatusCode int +} + +func (e *httpError) Error() string { + return "remote meta: HTTP " + strconv.Itoa(e.StatusCode) +} + +// --- sync fetch (no embedded, no cache) --- + +// doSyncFetch performs a blocking fetch for first-run without embedded data. +func doSyncFetch() { + fmt.Fprintf(os.Stderr, "Fetching API metadata...\n") + data, reg, err := fetchRemoteMerged(embeddedVersion) + if err != nil || reg == nil { + // Write meta even on failure so we don't retry every invocation within TTL + _ = saveCacheMeta(CacheMeta{ + LastCheckAt: time.Now().Unix(), + Brand: string(configuredBrand), + }) + return + } + meta := CacheMeta{ + LastCheckAt: time.Now().Unix(), + Version: reg.Version, + Brand: string(configuredBrand), + } + _ = saveCachedMerged(data, meta) + overlayMergedServices(reg) +} + +// --- background refresh --- + +var refreshOnce sync.Once + +func triggerBackgroundRefresh() { + refreshOnce.Do(func() { + go doBackgroundRefresh() + }) +} + +func doBackgroundRefresh() { + defer func() { _ = recover() }() + meta, _ := loadCacheMeta() + version := meta.Version + if version == "" { + version = embeddedVersion + } + data, reg, err := fetchRemoteMerged(version) + if err != nil { + // On error, update last_check_at to avoid retrying every invocation + meta.LastCheckAt = time.Now().Unix() + _ = saveCacheMeta(meta) + return + } + if reg == nil { + // Version unchanged — just update check time + meta.LastCheckAt = time.Now().Unix() + _ = saveCacheMeta(meta) + return + } + newMeta := CacheMeta{ + LastCheckAt: time.Now().Unix(), + Version: reg.Version, + Brand: string(configuredBrand), + } + _ = saveCachedMerged(data, newMeta) +} + +// shouldRefresh returns true if the cache TTL has expired. +func shouldRefresh(meta CacheMeta) bool { + if meta.LastCheckAt == 0 { + return true + } + return time.Since(time.Unix(meta.LastCheckAt, 0)) > metaTTL() +} + +// overlayMergedServices merges remote services into the in-memory map. +// Remote entries override embedded entries with the same name. +func overlayMergedServices(reg *MergedRegistry) { + for _, svc := range reg.Services { + name, ok := svc["name"].(string) + if !ok || name == "" { + continue + } + mergedServices[name] = svc + } +} diff --git a/internal/registry/remote_test.go b/internal/registry/remote_test.go new file mode 100644 index 00000000..1aa0f51a --- /dev/null +++ b/internal/registry/remote_test.go @@ -0,0 +1,480 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/larksuite/cli/internal/core" +) + +// resetInit resets the package-level state so each test starts fresh. +func resetInit() { + initOnce = sync.Once{} + mergedServices = make(map[string]map[string]interface{}) + mergedProjectList = nil + cachedAllScopes = nil + refreshOnce = sync.Once{} + configuredBrand = "" + enableRemoteMeta = true // tests exercise remote logic + testMetaURL = "" +} + +// hasEmbeddedData returns true if meta_data.json is compiled in. +func hasEmbeddedData() bool { + return len(embeddedMetaJSON) > 0 +} + +// testRegistry returns a minimal MergedRegistry with one service. +func testRegistry(name string) MergedRegistry { + return MergedRegistry{ + Version: "test-1.0", + Services: []map[string]interface{}{ + { + "name": name, + "version": "v1", + "title": name + " API", + "servicePath": "/open-apis/" + name + "/v1", + "resources": map[string]interface{}{}, + }, + }, + } +} + +// testCacheJSON returns a minimal valid MergedRegistry JSON (for cache files). +func testCacheJSON(name string) []byte { + data, _ := json.Marshal(testRegistry(name)) + return data +} + +// testEnvelopeJSON returns the remote API envelope format: {"msg":"succeeded","data":{...}}. +func testEnvelopeJSON(name string) []byte { + resp := remoteResponse{ + Msg: "succeeded", + Data: testRegistry(name), + } + data, _ := json.Marshal(resp) + return data +} + +// testEnvelopeNotModifiedJSON returns an envelope with empty data (version match). +func testEnvelopeNotModifiedJSON() []byte { + data, _ := json.Marshal(map[string]interface{}{ + "msg": "succeeded", + "data": map[string]interface{}{}, + }) + return data +} + +func TestColdStart_UsesEmbedded(t *testing.T) { + if !hasEmbeddedData() { + t.Skip("no embedded from_meta data") + } + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "off") + + Init() + + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Fatal("expected embedded projects, got none") + } + spec := LoadFromMeta("calendar") + if spec == nil { + t.Fatal("expected calendar spec from embedded data") + } +} + +func TestColdStart_NoEmbedded_SyncFetch(t *testing.T) { + if hasEmbeddedData() { + t.Skip("embedded data present, skipping no-embedded test") + } + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write(testEnvelopeJSON("remote_calendar")) + })) + defer ts.Close() + testMetaURL = ts.URL + + Init() + + if spec := LoadFromMeta("remote_calendar"); spec == nil { + t.Fatal("expected remote_calendar from sync fetch") + } +} + +func TestRemoteOff_SkipsRemoteLogic(t *testing.T) { + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "off") + + // Create a fake cache that should NOT be loaded + cDir := filepath.Join(tmp, "cache") + os.MkdirAll(cDir, 0700) + os.WriteFile(filepath.Join(cDir, "remote_meta.json"), testCacheJSON("fake_remote_svc"), 0644) + + Init() + + // "fake_remote_svc" should not be loaded when remote is off + if spec := LoadFromMeta("fake_remote_svc"); spec != nil { + t.Error("expected fake_remote_svc to NOT be loaded when remote is off") + } +} + +func TestCacheHit_WithinTTL(t *testing.T) { + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + t.Setenv("LARKSUITE_CLI_META_TTL", "3600") + + // Pre-seed cache with a custom service + cDir := filepath.Join(tmp, "cache") + os.MkdirAll(cDir, 0700) + os.WriteFile(filepath.Join(cDir, "remote_meta.json"), testCacheJSON("custom_svc"), 0644) + meta := CacheMeta{LastCheckAt: time.Now().Unix()} + metaData, _ := json.Marshal(meta) + os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644) + + // Point META_URL to a server that would fail if contacted + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("server should not be contacted when cache is within TTL") + w.WriteHeader(500) + })) + defer ts.Close() + testMetaURL = ts.URL + + Init() + + // custom_svc should be loaded from cache overlay + if spec := LoadFromMeta("custom_svc"); spec == nil { + t.Error("expected custom_svc from cache overlay") + } + // Embedded projects should still be present (if compiled in) + if hasEmbeddedData() { + if spec := LoadFromMeta("calendar"); spec == nil { + t.Error("expected calendar from embedded data") + } + } +} + +func TestNetworkError_SilentDegradation(t *testing.T) { + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + t.Setenv("LARKSUITE_CLI_META_TTL", "0") // Always refresh + + // Pre-seed cache so we have data to fall back on + cDir := filepath.Join(tmp, "cache") + os.MkdirAll(cDir, 0700) + os.WriteFile(filepath.Join(cDir, "remote_meta.json"), testCacheJSON("cached_svc"), 0644) + meta := CacheMeta{LastCheckAt: time.Now().Add(-2 * time.Hour).Unix()} + metaData, _ := json.Marshal(meta) + os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644) + + // Use a mock server that returns an error immediately (instead of 127.0.0.1:1 which + // may hang up to fetchTimeout=5s, leaking the background goroutine into subsequent tests). + fetched := make(chan struct{}, 1) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + select { + case fetched <- struct{}{}: + default: + } + })) + defer ts.Close() + testMetaURL = ts.URL + + // Should not panic or error + Init() + + projects := ListFromMetaProjects() + if len(projects) == 0 { + t.Fatal("expected projects after network error") + } + if spec := LoadFromMeta("cached_svc"); spec == nil { + t.Fatal("expected cached_svc after network error") + } + + // Wait for background goroutine to finish so it doesn't leak into subsequent tests. + select { + case <-fetched: + case <-time.After(5 * time.Second): + } + time.Sleep(50 * time.Millisecond) +} + +func TestShouldRefresh(t *testing.T) { + t.Setenv("LARKSUITE_CLI_META_TTL", "60") + + // Zero means never checked + if !shouldRefresh(CacheMeta{}) { + t.Error("expected shouldRefresh=true for zero LastCheckAt") + } + + // Recent check — no refresh needed + if shouldRefresh(CacheMeta{LastCheckAt: time.Now().Unix()}) { + t.Error("expected shouldRefresh=false for recent check") + } + + // Old check — refresh needed + if !shouldRefresh(CacheMeta{LastCheckAt: time.Now().Add(-2 * time.Minute).Unix()}) { + t.Error("expected shouldRefresh=true for old check") + } +} + +func TestRemoteEnabled(t *testing.T) { + // When feature flag is off, always disabled + enableRemoteMeta = false + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + if remoteEnabled() { + t.Error("expected disabled when feature flag is off") + } + + // When feature flag is on, env var controls + enableRemoteMeta = true + + t.Setenv("LARKSUITE_CLI_REMOTE_META", "off") + if remoteEnabled() { + t.Error("expected disabled when set to 'off'") + } + + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + if !remoteEnabled() { + t.Error("expected enabled when set to 'on'") + } + + t.Setenv("LARKSUITE_CLI_REMOTE_META", "") + if !remoteEnabled() { + t.Error("expected enabled when empty (default on)") + } +} + +func TestMetaTTL(t *testing.T) { + t.Setenv("LARKSUITE_CLI_META_TTL", "120") + if ttl := metaTTL(); ttl != 120*time.Second { + t.Errorf("expected 120s, got %v", ttl) + } + + t.Setenv("LARKSUITE_CLI_META_TTL", "") + if ttl := metaTTL(); ttl != defaultMetaTTL*time.Second { + t.Errorf("expected default %ds, got %v", defaultMetaTTL, ttl) + } + + t.Setenv("LARKSUITE_CLI_META_TTL", "invalid") + if ttl := metaTTL(); ttl != defaultMetaTTL*time.Second { + t.Errorf("expected default on invalid input, got %v", ttl) + } +} + +func TestOverlayMergedServices(t *testing.T) { + resetInit() + mergedServices = make(map[string]map[string]interface{}) + mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"} + + reg := &MergedRegistry{ + Services: []map[string]interface{}{ + {"name": "existing", "version": "v2"}, + {"name": "brand_new", "version": "v1"}, + }, + } + overlayMergedServices(reg) + + // existing should be overridden + if v := mergedServices["existing"]["version"].(string); v != "v2" { + t.Errorf("expected existing to be overridden to v2, got %s", v) + } + // brand_new should be added + if _, ok := mergedServices["brand_new"]; !ok { + t.Error("expected brand_new to be added") + } +} + +func TestFetchRemoteMerged_200(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write(testEnvelopeJSON("fetched_svc")) + })) + defer ts.Close() + testMetaURL = ts.URL + + data, reg, err := fetchRemoteMerged("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if reg == nil { + t.Fatal("expected non-nil registry") + } + if data == nil { + t.Fatal("expected non-nil data") + } + if reg.Version != "test-1.0" { + t.Errorf("expected version test-1.0, got %s", reg.Version) + } +} + +func TestFetchRemoteMerged_VersionMatch(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write(testEnvelopeNotModifiedJSON()) + })) + defer ts.Close() + testMetaURL = ts.URL + + data, reg, err := fetchRemoteMerged("test-1.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if reg != nil { + t.Error("expected nil registry for version match (not modified)") + } + if data != nil { + t.Error("expected nil data for version match") + } +} + +func TestFetchRemoteMerged_ServerError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(503) + })) + defer ts.Close() + testMetaURL = ts.URL + + _, _, err := fetchRemoteMerged("") + if err == nil { + t.Fatal("expected error for 503") + } + httpErr, ok := err.(*httpError) + if !ok { + t.Fatalf("expected *httpError, got %T", err) + } + if httpErr.StatusCode != 503 { + t.Errorf("expected 503, got %d", httpErr.StatusCode) + } +} + +func TestCorruptedCache_SelfHeals(t *testing.T) { + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + + // Write corrupted cache + cDir := filepath.Join(tmp, "cache") + os.MkdirAll(cDir, 0700) + os.WriteFile(filepath.Join(cDir, "remote_meta.json"), []byte("not json{{{"), 0644) + meta := CacheMeta{LastCheckAt: time.Now().Unix()} + metaData, _ := json.Marshal(meta) + os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644) + + // loadCachedMerged should fail and remove the corrupted files + _, err := loadCachedMerged() + if err == nil { + t.Fatal("expected error for corrupted cache") + } + + // Corrupted files should be deleted + if _, err := os.Stat(filepath.Join(cDir, "remote_meta.json")); !os.IsNotExist(err) { + t.Error("expected corrupted remote_meta.json to be deleted") + } + if _, err := os.Stat(filepath.Join(cDir, "remote_meta.meta.json")); !os.IsNotExist(err) { + t.Error("expected remote_meta.meta.json to be deleted") + } +} + +func TestFetchRemoteMerged_InvalidJSON(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte("not json")) + })) + defer ts.Close() + testMetaURL = ts.URL + + _, _, err := fetchRemoteMerged("") + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestBrandSwitchInvalidatesCache(t *testing.T) { + // Wait for any background goroutines from previous tests to settle + time.Sleep(200 * time.Millisecond) + resetInit() + tmp := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) + t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") + t.Setenv("LARKSUITE_CLI_META_TTL", "3600") + + // Pre-seed cache with feishu brand + cDir := filepath.Join(tmp, "cache") + os.MkdirAll(cDir, 0700) + os.WriteFile(filepath.Join(cDir, "remote_meta.json"), testCacheJSON("feishu_svc"), 0644) + meta := CacheMeta{LastCheckAt: time.Now().Unix(), Version: "test-1.0", Brand: "feishu"} + metaData, _ := json.Marshal(meta) + os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644) + + // Server returns lark-specific data + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write(testEnvelopeJSON("lark_svc")) + })) + defer ts.Close() + testMetaURL = ts.URL + + // Init with lark brand — should invalidate feishu cache and sync fetch + InitWithBrand(core.BrandLark) + + // The old feishu_svc should NOT be loaded from stale cache + // The new lark_svc from sync fetch should be available + if spec := LoadFromMeta("lark_svc"); spec == nil { + t.Error("expected lark_svc after brand switch sync fetch") + } +} + +func TestRemoteMetaURL_BrandSpecific(t *testing.T) { + testMetaURL = "" + + // Default URL (feishu) with no version + configuredBrand = core.BrandFeishu + u := remoteMetaURL("") + if !strings.Contains(u, "open.feishu.cn") { + t.Errorf("expected feishu URL, got %s", u) + } + if strings.Contains(u, "data_version") { + t.Errorf("expected no data_version param for empty version, got %s", u) + } + + // Lark brand with version param + configuredBrand = core.BrandLark + u = remoteMetaURL("1.0.3") + if !strings.Contains(u, "open.larksuite.com") { + t.Errorf("expected lark URL, got %s", u) + } + if !strings.Contains(u, "data_version=1.0.3") { + t.Errorf("expected data_version=1.0.3, got %s", u) + } + + // testMetaURL override takes precedence + testMetaURL = "http://custom.example.com/meta" + u = remoteMetaURL("ignored") + if u != "http://custom.example.com/meta" { + t.Errorf("expected testMetaURL override, got %s", u) + } +} diff --git a/internal/registry/scope_overrides.json b/internal/registry/scope_overrides.json new file mode 100644 index 00000000..8287248e --- /dev/null +++ b/internal/registry/scope_overrides.json @@ -0,0 +1,50 @@ +{ + "priority_overrides": { + "im:chat:readonly": 1, + "im:message:send_as_bot": 1, + "calendar:calendar:read": 70, + "calendar:calendar:readonly": 1, + "sheets:spreadsheet:write_only": 45, + "docs:document.comment:delete": 60, + "drive:drive:readonly": 1, + "docs:doc:readonly": 1, + "sheets:spreadsheet:readonly": 1, + "vc:meeting:readonly": 1, + "vc:meeting.meetingevent:read": 75 + }, + "recommend": { + "allow": [ + "calendar:calendar.event:create", + "calendar:calendar.event:delete", + "calendar:calendar.event:read", + "calendar:calendar.event:update", + "calendar:calendar.free_busy:read", + "calendar:calendar:create", + "calendar:calendar:delete", + "calendar:calendar:read", + "calendar:calendar:update", + "contact:user.basic_profile:readonly", + "mail:event", + "mail:user_mailbox.mail_contact:read", + "mail:user_mailbox.mail_contact:write", + "mail:user_mailbox.message.address:read", + "mail:user_mailbox.message.body:read", + "mail:user_mailbox.message.subject:read", + "mail:user_mailbox.message:readonly" + ], + "deny": [ + "im:chat", + "im:message.send_as_user" + ], + "_deny": [ + "mail:user_mailbox.folder:read", + "mail:user_mailbox.folder:write", + "mail:user_mailbox.message:modify", + "mail:user_mailbox.message:readonly", + "mail:user_mailbox.message:send", + "mail:user_mailbox:readonly", + "sheets:spreadsheet", + "sheets:spreadsheet:readonly" + ] + } +} \ No newline at end of file diff --git a/internal/registry/scope_priorities.json b/internal/registry/scope_priorities.json new file mode 100644 index 00000000..ef73add3 --- /dev/null +++ b/internal/registry/scope_priorities.json @@ -0,0 +1,5522 @@ +[ + { + "scope_name": "directory:employee.work.resign_remark:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "im:message:send_multi_depts", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "hire:evaluation:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "calendar:time_off:delete", + "final_score": "60.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.probation_exist:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:department:list", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.gender:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.data_source:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:group", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "document_ai:id_card:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:process:read", + "final_score": "49.7643", + "recommend": "false" + }, + { + "scope_name": "corehr:work_calendar:read", + "final_score": "71.6425", + "recommend": "false" + }, + { + "scope_name": "cardkit:card:read", + "final_score": "71.8295", + "recommend": "false" + }, + { + "scope_name": "app_engine:attachment:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "app_engine:approval:read", + "final_score": "57.9638", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.duration_type:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:talent_tag:readonly", + "final_score": "79.8295", + "recommend": "false" + }, + { + "scope_name": "trust_party:collaboration_rule:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.cost_center:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.work_country_or_region:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "minutes:minutes", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "admin:badge.grant", + "final_score": "62.7507", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.compensation_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.abnormal_reason_field:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "okr:okr.progress:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:object.record:write", + "final_score": "59.5638", + "recommend": "false" + }, + { + "scope_name": "directory:employee.resign:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:orgrole_info:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:record_permission.member:write", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "application:bot.menu:readonly", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "search:data_source", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employee.update:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.actual_probation_end_date:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "contact:department.base:readonly", + "final_score": "74.1705", + "recommend": "true" + }, + { + "scope_name": "im:chat.moderation:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "approval:approval.list:readonly", + "final_score": "61.4918", + "recommend": "false" + }, + { + "scope_name": "im:message:update", + "final_score": "77.7705", + "recommend": "true" + }, + { + "scope_name": "directory:department.count:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "document_ai:food_produce_license:recoginze", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "directory:employee.work.job_title:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "directory:place.base:read", + "final_score": "61.4918", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.signing_type:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "payroll:cost_allocation_report:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:message:send_multi_users", + "final_score": "69.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:position.job_family:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "docs:permission.member:auth", + "final_score": "71.8295", + "recommend": "true" + }, + { + "scope_name": "hire:site_application:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "approval:instance.comment", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.compensation_type:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.custom_field.apaas_id__c:read", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.dependent:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:position.employee_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.custom_field:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_archive:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job.job_level:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.job_level:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.position:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "mdm:country_region:read", + "final_score": "71.8295", + "recommend": "false" + }, + { + "scope_name": "im:chat.collab_plugins:write_only", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "contact:department.organize:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:department.organize.search:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:department.create:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet:readonly", + "final_score": "57.0000", + "recommend": "true" + }, + { + "scope_name": "hire:offer_selection_object", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "payroll:external_datasource_configuration:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.is_disabled:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "search:data_schemas:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:site_application", + "final_score": "46.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.position:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docx:document", + "final_score": "44.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:pre_hire:transit_tasks", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar:create", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet.meta:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "application:application.app_usage_stats.overview:readonly", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.active_status:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:job_family:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_end_date:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.service_company:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "spark:data.record.change:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "aily:file:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "app_engine:role.member:write", + "final_score": "72.7705", + "recommend": "true" + }, + { + "scope_name": "search:app", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.national_id:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "base:app:create", + "final_score": "70.4295", + "recommend": "true" + }, + { + "scope_name": "contact:user.gender:readonly", + "final_score": "53.4918", + "recommend": "false" + }, + { + "scope_name": "docs:document:copy", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.status_message_v2:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.working_calendar:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "minutes:minutes.basic:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.archive_cpst_plan:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.geo:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:cost_allocation:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "task:comment:write", + "final_score": "70.7507", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation_archive_detail.plan:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "admin:app.admin:readonly", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "hire:note", + "final_score": "44.4430", + "recommend": "false" + }, + { + "scope_name": "base:workspace:list", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "hire:subject:readonly", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "application:application.app_version:readonly", + "final_score": "63.9638", + "recommend": "false" + }, + { + "scope_name": "docs:permission.member:create", + "final_score": "72.7705", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.company:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_number:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "mdm:vendor", + "final_score": "41.6590", + "recommend": "false" + }, + { + "scope_name": "im:chat:create", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "corehr:additional_job.job_level:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "slides:presentation:update", + "final_score": "59.5638", + "recommend": "true" + }, + { + "scope_name": "corehr:job_data:write", + "final_score": "59.5638", + "recommend": "false" + }, + { + "scope_name": "corehr:person.email:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:file:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "docx:document:write_only", + "final_score": "52.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:probation.probation_expected_end_date:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "payroll:external_datasource_record:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.martyr_family:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "drive:drive:version:readonly", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "payroll:payment_activity:monitor", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "drive:file", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:person.address:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "approval:external_instance", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "contact:department.hrbp:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:external_referral_reward", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:employment:read", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "search:dataset:create", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.custom_field:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:application.event:read", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "hire:tripartite_agreement", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "document_ai:health_certificate:recognize", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "im:url_preview.update", + "final_score": "72.7705", + "recommend": "true" + }, + { + "scope_name": "vc:meeting.search:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "attendance:task:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "performance:metric:write", + "final_score": "53.0430", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.job_level:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.message:send", + "final_score": "50.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:person.race:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation.insurance:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.cost_center:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "document_ai:hkm_mainland_travel_permit:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "aily:session:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.event:read", + "final_score": "50.5853", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.employee_subtype:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.is_admin:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:leave_grant:write", + "final_score": "67.2425", + "recommend": "false" + }, + { + "scope_name": "corehr:person.email:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet:write_only", + "final_score": "52.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.probation_exist:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:contract.company:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.probation_start_date:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_archive_detail.items:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "performance:semester_user:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.message:modify", + "final_score": "57.6000", + "recommend": "true" + }, + { + "scope_name": "docs:permission.member:delete", + "final_score": "71.8295", + "recommend": "true" + }, + { + "scope_name": "directory:department.idconvert:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "search:dataset.docs:create", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.marital_status:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:employee.job_data:read", + "final_score": "67.1507", + "recommend": "false" + }, + { + "scope_name": "im:chat.tabs:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:person.religion:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "application:application.contacts_range:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.office_address:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:doc", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "contact:job_level:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "task:task:readonly", + "final_score": "78.4430", + "recommend": "true" + }, + { + "scope_name": "corehr:person.political_affiliation:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.offboarding_reason.search:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "base:record:delete", + "final_score": "68.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:pre_hire:delete", + "final_score": "68.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:bp.list:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.social_insurance:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.weekly_working_hours:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:cost_center:read", + "final_score": "67.1507", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.mail_contact.mail_address:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "admin:app.enable:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:user_migration:multi-geo", + "final_score": "44.4430", + "recommend": "false" + }, + { + "scope_name": "im:tag:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.assignment:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:external_offer:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "application:application.app_message_stats.overview:readonly", + "final_score": "71.6425", + "recommend": "false" + }, + { + "scope_name": "hire:exam", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job_grade:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation.social_adjust_record:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "base:field:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "drive:file:download", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:leaves:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.notes:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "document_ai:food_manage_license:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:contract.period:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "im:chat.chat_pins:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.political_affiliation:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.born_country_region:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.compensation_type:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "docs:permission.setting:readonly", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "wiki:wiki:readonly", + "final_score": "65.0000", + "recommend": "true" + }, + { + "scope_name": "application:application", + "final_score": "52.4430", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_type:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:cost_center:write", + "final_score": "56.0359", + "recommend": "false" + }, + { + "scope_name": "contact:contact:update_department_id", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "minutes:minutes:readonly", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "docx:document:create", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:cost_allocation:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.join_date:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "drive:file:readonly", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:international_assignment:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.dotted_line_leaders:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "docs:document.comment:create", + "final_score": "72.7705", + "recommend": "true" + }, + { + "scope_name": "moments:moments:readonly", + "final_score": "57.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:person.born_country_region:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.work_shift:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:transform_onboarding_task", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "base:dashboard:copy", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "directory:employee.work.positions:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "payroll:payroll_calculation_item:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:app_feed_card:write", + "final_score": "58.2590", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox", + "final_score": "46.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.job_grade:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.company:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "search:data_source:create", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.event:create", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "hire:attachment:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "task:task:write", + "final_score": "60.6000", + "recommend": "true" + }, + { + "scope_name": "aily:data_asset:write", + "final_score": "83.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:person.work_experience:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "search:department:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.company_sponsored_visa:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.collab_plugins:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.native_region:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "docs:document.comment:write_only", + "final_score": "50.9638", + "recommend": "true" + }, + { + "scope_name": "corehr:position.job:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.rule:write", + "final_score": "53.0430", + "recommend": "false" + }, + { + "scope_name": "payroll:external_datasource_record:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:custom_field:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "directory:employee:read", + "final_score": "55.9638", + "recommend": "false" + }, + { + "scope_name": "minutes:minutes.media:export", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:message:readonly", + "final_score": "70.0000", + "recommend": "true" + }, + { + "scope_name": "base:record:read", + "final_score": "77.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:employee.event:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.employment_custom_field:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat:operate_as_owner", + "final_score": "68.6425", + "recommend": "true" + }, + { + "scope_name": "directory:employee.base.resign_time:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.phone:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:doc:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet:read", + "final_score": "65.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:person.citizenship_status:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "admin:badge", + "final_score": "54.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.compensation_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "document_ai:resume:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "vc:meeting.meetingevent:read", + "final_score": "67.4720", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.work_shift:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:read", + "final_score": "59.4720", + "recommend": "false" + }, + { + "scope_name": "base:dashboard:delete", + "final_score": "76.3213", + "recommend": "true" + }, + { + "scope_name": "acs:devices:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "app_engine:apps:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "task:task", + "final_score": "52.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:person.nationality:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.work_place:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "task:task.event_update_tenant:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:agency_account:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_plan:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet", + "final_score": "44.0000", + "recommend": "true" + }, + { + "scope_name": "vc:export", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "slides:presentation:write_only", + "final_score": "50.9638", + "recommend": "true" + }, + { + "scope_name": "directory:employee:list", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.resident_tax:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "spark:app.sql_commands:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "wiki:member:create", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "wiki:space:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "directory:employee.base.description:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "okr:okr", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "wiki:member:update", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:person.national_id.search:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.position:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.offboarding_reason:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:place:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "contact:role:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "base:record:update", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "directory:employee.work.resign_reason:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "im:message:send_sys_msg", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "base:field:update", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:process.detail:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.work_location:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "cardkit:card:write", + "final_score": "54.0918", + "recommend": "false" + }, + { + "scope_name": "space:document:shortcut", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "im:biz_entity_tag_relation:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "docx:document:readonly", + "final_score": "65.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.job:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:background_check_order", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:workforce_plan_centralized_reporting_project_detail:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:person.resident_tax_custom_field:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "helpdesk:all", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "im:chat:readonly", + "final_score": "62.0000", + "recommend": "true" + }, + { + "scope_name": "im:message.group_msg:get_as_user", + "final_score": "66.3213", + "recommend": "true" + }, + { + "scope_name": "search:data_schemas:create", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "baike:entity", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.work_location:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "tenant:tenant.product_assign_info:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_plan_detail.items:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "mail:public_mailbox:readonly", + "final_score": "67.1507", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.folder:write", + "final_score": "59.7507", + "recommend": "false" + }, + { + "scope_name": "hire:interview:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "im:message.send_as_user", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "app_engine:workspace.table:write", + "final_score": "64.7705", + "recommend": "false" + }, + { + "scope_name": "contact:user.department_path:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet:create", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:probation:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "spark:app.table.record:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "app_engine:application.event_subscriber:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "drive:drive", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "hire:tripartite_agreement:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job_family:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:common_data.preset_data:read", + "final_score": "60.4359", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.remark:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "hire:external_offer", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "task:tasklist:read", + "final_score": "75.6590", + "recommend": "true" + }, + { + "scope_name": "block:message", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.block_list:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "drive:drive.search:readonly", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "contact:user.assign_info:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.event:delete", + "final_score": "60.3213", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:user_migration", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.name.first_name:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "document_ai:bank_card:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "document_ai:business_license:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "docx:document.block:convert", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "directory:department.status:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "okr:okr.progress:delete", + "final_score": "68.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.block_list:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.assessment:write", + "final_score": "61.0430", + "recommend": "false" + }, + { + "scope_name": "document_ai:taxi_invoice:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:signature_template:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:person.date_of_birth:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.flow_id:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.acl:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "im:message.urgent", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "base:record:create", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "corehr:person.personal_profile:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "hire:talent_folder_association", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_number:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:file:download", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:exchange.bindings:delete", + "final_score": "60.3213", + "recommend": "false" + }, + { + "scope_name": "base:table:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "board:whiteboard:node:update", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "search:data_schemas:update", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:employee.bp:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "application:application:self_manage", + "final_score": "65.9638", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.work_shift:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "vc:reserve:readonly", + "final_score": "87.8295", + "recommend": "true" + }, + { + "scope_name": "task:attachment:write", + "final_score": "70.7507", + "recommend": "true" + }, + { + "scope_name": "admin:admin_user_stat:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "performance:performance", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "docs:permission.setting", + "final_score": "68.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.work_shift:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "base:workflow:write", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.bt:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.hukou:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "mail:event", + "final_score": "56.1705", + "recommend": "false" + }, + { + "scope_name": "docs:document:import", + "final_score": "70.7507", + "recommend": "true" + }, + { + "scope_name": "corehr:person.date_entered_workforce:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "performance:semester_activity:write", + "final_score": "54.7507", + "recommend": "false" + }, + { + "scope_name": "contact:functional_role", + "final_score": "43.6590", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.probation_outcome:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "mdm:legal_entity:readonly", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "contact:user.employee:readonly", + "final_score": "51.4720", + "recommend": "false" + }, + { + "scope_name": "base:view:write_only", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "im:message.p2p_msg:readonly", + "final_score": "73.4359", + "recommend": "true" + }, + { + "scope_name": "directory:department.delete:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "directory:place.status:read", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "hire:offer_approval_template:readonly", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "document_ai:business_card:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:draft:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:document.content:read", + "final_score": "77.1705", + "recommend": "true" + }, + { + "scope_name": "document_ai:vehicle_invoice:recognize", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:person.entry_leave_time:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.announcement:read", + "final_score": "75.1507", + "recommend": "true" + }, + { + "scope_name": "baike:entity:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.contract_type:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.resign_date:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "hire:talent_folder:readonly", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "performance:metric_lib:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "attendance_machine:device:write", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "calendar:settings.caldav:create", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "base:field:create", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "drive:file.like:readonly", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "directory:employee.work.work_station:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:person.religion:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "approval:task", + "final_score": "50.9638", + "recommend": "false" + }, + { + "scope_name": "component:selector", + "final_score": "66.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.weekly_working_hours:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "im:chat.top_notice:write_only", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "mdm:vendor:readonly", + "final_score": "52.4359", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.job_grade:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:flow:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "spark:app.table:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "docs:document.subscription:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "base:form:delete", + "final_score": "76.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:probation.submit:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "im:chat.managers:write_only", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:person.education:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:department.base:read", + "final_score": "58.5853", + "recommend": "true" + }, + { + "scope_name": "hire:site:readonly", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.custom_field:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "board:whiteboard:node:delete", + "final_score": "68.6425", + "recommend": "true" + }, + { + "scope_name": "docs:permission.member", + "final_score": "61.8295", + "recommend": "true" + }, + { + "scope_name": "component:user_profile", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:pathway:write", + "final_score": "58.2590", + "recommend": "false" + }, + { + "scope_name": "acs:access_record:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.compensation_type:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:process.instance:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.has_offer_salary:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.access_event.bot_p2p_chat:read", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "im:chat:moderation:write_only", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "base:dashboard:create", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "document_ai:chinese_passport:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "collab_plugins:collab_plugins", + "final_score": "61.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.job_family:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.working_hours_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "translation:text", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "application:application.app_package", + "final_score": "50.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:contract:create", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "contact:user.job_family:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "collab_plugins:collab_plugins.relation.change:read", + "final_score": "79.8295", + "recommend": "true" + }, + { + "scope_name": "wiki:node:copy", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.custom_org:write", + "final_score": "67.7507", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.status:read", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "vc:record:readonly", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "search:message", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "slides:presentation:create", + "final_score": "74.9213", + "recommend": "true" + }, + { + "scope_name": "contact:user.dotted_line_leader_info.read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "drive:file:view_record:readonly", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation_archive_detail:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.assessment.submit2:write", + "final_score": "67.2425", + "recommend": "false" + }, + { + "scope_name": "passport:session:logout", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "hire:referral:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:position.job_level:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:document.media:download", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation.social_plan:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:employee:read", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:signature_file.pre_hire:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "task:section:read", + "final_score": "80.1507", + "recommend": "true" + }, + { + "scope_name": "aily:message:write", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:audit_log.openapi_log:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "admin:app.user_usable:readonly", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.education:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "minutes:minutes.statistics:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "board:whiteboard:node:create", + "final_score": "67.5638", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.job_level:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "base:app:copy", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "aily:file:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.cost_center:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.pathway:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "base:role:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.checklist_status_message:read", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.workforce_type:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "im:message", + "final_score": "49.0000", + "recommend": "true" + }, + { + "scope_name": "tenant:tenant.domain:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:complete", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:workspace.table:read", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "approval:instance", + "final_score": "49.6590", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.folder:read", + "final_score": "71.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.withdrawn_reason:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "space:document:delete", + "final_score": "68.6425", + "recommend": "true" + }, + { + "scope_name": "base:role:create", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "wiki:space:retrieve", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "vc:meeting", + "final_score": "58.9638", + "recommend": "true" + }, + { + "scope_name": "tenant:tenant:readonly", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "security_and_compliance:user_migration_task", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "mail:public_mailbox", + "final_score": "44.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.work_calendar:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "wiki:node:update", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "app_engine:dataset.meta:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "docs:permission.member:transfer", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:pre_hire.search:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:workforce_detail:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.subscription_ids:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.event:reply", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job.only:read", + "final_score": "71.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job_grade:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "wiki:node:move", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:person.marital_status:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.non_compete_covenant:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:common_data.preset_data:write", + "final_score": "52.6000", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar:delete", + "final_score": "60.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.job_family:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "drive:file.meta.sec_label.read_only", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.update_field_message:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.custom_field:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:leave_granting_record:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "document_ai:vehicle_license:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "helpdesk:all:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "vc:record", + "final_score": "66.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation.social_archive:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "admin:app.admin:check", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "report:report", + "final_score": "66.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.update_event_v2:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.base_work:read", + "final_score": "54.6590", + "recommend": "false" + }, + { + "scope_name": "corehr:company:write", + "final_score": "59.5638", + "recommend": "false" + }, + { + "scope_name": "corehr:authorization:read", + "final_score": "65.4430", + "recommend": "false" + }, + { + "scope_name": "im:datasync.feed_card.time_sensitive:write", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.job_level:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "hire:interviewer:readonly", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "aily:data_asset:upload_file", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "contact:user.employee_number:read", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "wiki:node:retrieve", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:international_assignment:write", + "final_score": "64.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:position.job_grade:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "search:data_item:create", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "bitable:app", + "final_score": "44.0000", + "recommend": "true" + }, + { + "scope_name": "directory:department.parent_id:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.job_grade:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person:write", + "final_score": "59.5638", + "recommend": "false" + }, + { + "scope_name": "app_engine:workspace.table.record:write", + "final_score": "61.0430", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar", + "final_score": "44.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.retain_account:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "hire:questionnaire:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "vc:room:readonly", + "final_score": "54.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_start_date:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.chat_pins:write_only", + "final_score": "56.1705", + "recommend": "false" + }, + { + "scope_name": "myai_data:myai_data:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:message.group_msg", + "final_score": "62.1507", + "recommend": "true" + }, + { + "scope_name": "corehr:signature_file:terminate", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.social_security_city:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:position.working_hours_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "base:collaborator:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "ehr:attachment:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "spark:app.table.record:write", + "final_score": "53.0430", + "recommend": "false" + }, + { + "scope_name": "corehr:position:write", + "final_score": "49.0918", + "recommend": "false" + }, + { + "scope_name": "corehr:person.native_region:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "hire:auth:readonly", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.update:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.assignment_pay_group:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.tabs:write_only", + "final_score": "62.1507", + "recommend": "true" + }, + { + "scope_name": "directory:employee.base.geo:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:group:readonly", + "final_score": "49.7643", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change:create", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "hire:talent_folder", + "final_score": "40.4918", + "recommend": "false" + }, + { + "scope_name": "minutes:minutes.transcript:export", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "bitable:app:readonly", + "final_score": "57.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.job_level:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.job_grade:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "directory:department.name:read", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:additional_job:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "im:biz_entity_tag_relation:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:message.pins:write_only", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.retain_account:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:department.cost_center_id:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.notes:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "contact:user.subscription_ids:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "acs:access_record:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.work_location:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation.recurring_payment:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "task:custom_field:read", + "final_score": "87.8295", + "recommend": "true" + }, + { + "scope_name": "vc:meeting.all_meeting:readonly", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "im:chat.menu_tree:write_only", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "directory:employee.base.department:read", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "approval:external_approval", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.work_calendar:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.score:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:site", + "final_score": "41.6590", + "recommend": "false" + }, + { + "scope_name": "hire:auth", + "final_score": "46.4430", + "recommend": "false" + }, + { + "scope_name": "okr:okr.content:writeonly", + "final_score": "44.0000", + "recommend": "false" + }, + { + "scope_name": "base:collaborator:delete", + "final_score": "68.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:offboarding.submit:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.phone:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.position:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:permission.setting:write_only", + "final_score": "60.4430", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation.recurring_payment:update", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "hire:job_requirement", + "final_score": "40.4918", + "recommend": "false" + }, + { + "scope_name": "vc:meeting:readonly", + "final_score": "62.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.recruitment_project_id:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "performance:metric:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "im:user_agent:read", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:probation:write", + "final_score": "62.7507", + "recommend": "false" + }, + { + "scope_name": "admin:ent_email_password", + "final_score": "66.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.compensation_type:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "contact:user.employee_id:readonly", + "final_score": "65.0000", + "recommend": "true" + }, + { + "scope_name": "mail:user_mailbox.mail_contact:write", + "final_score": "69.7705", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.employment_type:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "security_and_compliance:device_record:write", + "final_score": "53.0430", + "recommend": "false" + }, + { + "scope_name": "corehr:flow.definition:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.role:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "payroll:cost_allocation_plan:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.work_shift:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:site_job_post:readonly", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "app_engine:object.meta:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:job_grade:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.leader:read", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "mail:user_mailbox:readonly", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "im:resource", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "base:field_group:create", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "hire:interview", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "sheets:spreadsheet.meta:write_only", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:pre_hire:update", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "task:attachment:read", + "final_score": "87.8295", + "recommend": "true" + }, + { + "scope_name": "vc:report:readonly", + "final_score": "77.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation_item:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:corehr", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "contact:functional_role:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "approval:approval", + "final_score": "44.0000", + "recommend": "false" + }, + { + "scope_name": "docs:document.media:upload", + "final_score": "70.7507", + "recommend": "true" + }, + { + "scope_name": "search:memory_graph_tool_call:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "search:data_schemas:delete", + "final_score": "68.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_indicator:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.acl:delete", + "final_score": "60.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.resurrect:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "vc:room", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "space:document.event:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "spark:directory.user.id_convert:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:time_off:create", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "application:bot.menu:write", + "final_score": "75.4295", + "recommend": "false" + }, + { + "scope_name": "trust_party:collaboration.tenant:readonly", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "task:task:writeonly", + "final_score": "44.0000", + "recommend": "false" + }, + { + "scope_name": "base:collaborator:create", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "im:message.urgent.status:write", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "corehr:person.emergency_contact:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "im:message.reactions:write_only", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.job:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.base:read", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "space:document:retrieve", + "final_score": "44.4430", + "recommend": "false" + }, + { + "scope_name": "corehr:person.gender:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.additional_nationalities:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:write", + "final_score": "52.6000", + "recommend": "false" + }, + { + "scope_name": "admin:admin_dept_stat:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.email:read", + "final_score": "67.1507", + "recommend": "true" + }, + { + "scope_name": "aily:knowledge:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "mail:user_mailbox.message.subject:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:device_apply_record:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "base:view:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.compensation_type:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "baike:entity:exempt_review", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:job_data.work_shift:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:locations:read", + "final_score": "61.4918", + "recommend": "false" + }, + { + "scope_name": "document_ai:contract:field_extract", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "base:role:delete", + "final_score": "71.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:approval_groups.orgdraft_job_change:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:department.manager.search:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "payroll:payment_activity:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job_level:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "calendar:room", + "final_score": "39.4359", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.signature:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "base:table:update", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.position:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "app_engine:seat_assignments:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.noncompete_agreement:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "minutes:minutes.artifacts:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:common_data.id.convert:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_change_reason:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:global_option:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.name.another_name:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_data.compensation_type:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.members:read", + "final_score": "71.9638", + "recommend": "true" + }, + { + "scope_name": "aily:data_asset:read", + "final_score": "76.6425", + "recommend": "false" + }, + { + "scope_name": "app_engine:application.metric:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.position:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.job_level:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.personal_profile:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.members:bot_access", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "directory:department.order_weight:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "aily:run:write", + "final_score": "75.4295", + "recommend": "false" + }, + { + "scope_name": "optical_char_recognition:image", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "im:chat.announcement:write_only", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "base:workflow:update", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "im:message.urgent:phone", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "search:docs:read", + "final_score": "64.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.company_manual_updated:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "task:tasklist:write", + "final_score": "60.6000", + "recommend": "true" + }, + { + "scope_name": "corehr:job:write", + "final_score": "53.3643", + "recommend": "false" + }, + { + "scope_name": "corehr:signature_file:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "app_engine:application.function:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "directory:employee:search", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.create:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:department.operation_log:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:agency_account", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.job:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "hire:offer", + "final_score": "40.4918", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.message.body:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "wiki:setting:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "approval:definition", + "final_score": "54.1507", + "recommend": "false" + }, + { + "scope_name": "base:dashboard:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:department:write", + "final_score": "44.6000", + "recommend": "false" + }, + { + "scope_name": "corehr:locations:write", + "final_score": "52.6000", + "recommend": "false" + }, + { + "scope_name": "docs:permission.member:retrieve", + "final_score": "62.1507", + "recommend": "true" + }, + { + "scope_name": "hire:talent_tag", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "document_ai:tw_mainland_travel_permit:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "im:message.p2p_msg:get_as_user", + "final_score": "66.3213", + "recommend": "true" + }, + { + "scope_name": "attendance:task", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.weekly_working_hours:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "drive:drive.metadata:readonly", + "final_score": "66.5853", + "recommend": "true" + }, + { + "scope_name": "app_engine:object.record:read", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:employees.international_assignment:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "offline_access", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "search:data_source:delete", + "final_score": "68.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.last_attendance_date:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "base:table:delete", + "final_score": "68.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.cost_center:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "contact:job_title:readonly", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "docs:document.comment:read", + "final_score": "73.4430", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.remark:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "search:data_source:readonly", + "final_score": "57.7643", + "recommend": "false" + }, + { + "scope_name": "contact:contact:update_user_id", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "docs:permission.setting:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "task:task.privilege:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "vc:reserve", + "final_score": "66.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:person.is_old_alone:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "im:chat", + "final_score": "49.0000", + "recommend": "true" + }, + { + "scope_name": "okr:okr.progress.file:upload", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "calendar:exchange.bindings:create", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.enterprise_email:read", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "hire:agency", + "final_score": "44.4430", + "recommend": "false" + }, + { + "scope_name": "im:chat:update", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "attendance_machine:check_in_record:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.bank_account:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.staff_status:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:user.basic_profile:readonly", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "docs:document.subscription", + "final_score": "66.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation_archive_detail.change_description:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "contact:user.job_level:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:user.email:readonly", + "final_score": "65.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:custom_org:write", + "final_score": "56.0359", + "recommend": "false" + }, + { + "scope_name": "contact:user:search", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "payroll:payment_details:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "personal_settings:status:system_status_update", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:person.race:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:permission.member:update", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:additional_job.service_company:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.dependent:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "drive:file:upload", + "final_score": "70.7507", + "recommend": "true" + }, + { + "scope_name": "docs:permission.member:readonly", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "directory:job_title.base:read", + "final_score": "51.4720", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding:read", + "final_score": "55.9638", + "recommend": "false" + }, + { + "scope_name": "corehr:person.phone.search:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:application.log:read", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "hire:referral_account:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "docs:document.comment:update", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "corehr:workforce_detail:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:person.is_disabled:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "application:application.collaborators:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "application:application.bot.operator_name:readonly", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "search:data_item:delete", + "final_score": "68.3213", + "recommend": "false" + }, + { + "scope_name": "hire:external_application:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.mail_contact.phone:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "mail:mailgroup", + "final_score": "44.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:employees.international_assignment:write", + "final_score": "54.7507", + "recommend": "false" + }, + { + "scope_name": "wiki:space:write_only", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.custom_field:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:device_apply_record:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "mdm:legal_entity", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "base:app:read", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:additional_job.job:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding:write", + "final_score": "51.5638", + "recommend": "false" + }, + { + "scope_name": "corehr:employee.all_bp:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:contract:read", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation.lump_sum_payment:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "passport:session_mask:readonly", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "hire:advertisement", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "wiki:wiki", + "final_score": "44.0000", + "recommend": "true" + }, + { + "scope_name": "contact:unit:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.non_compete_covenant:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_level:read", + "final_score": "63.9638", + "recommend": "false" + }, + { + "scope_name": "corehr:security_group:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "admin:app.category:update", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "okr:okr.progress:writeonly", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "hire:agency:readonly", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "performance:performance:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "hire:background_check_order:readonly", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.pathway:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.name.name:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.probation_end_date:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.date_entered_workforce:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_start_date:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.extended_probation_period_duration:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.self_review:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "hire:application", + "final_score": "37.5853", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:user_migration:readonly", + "final_score": "71.8295", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.message:readonly", + "final_score": "49.7643", + "recommend": "false" + }, + { + "scope_name": "docs:event.document_edited:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "aily:skill:write", + "final_score": "87.9213", + "recommend": "true" + }, + { + "scope_name": "acs:device:write", + "final_score": "51.5638", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.custom_field:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.work_experience:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:position.direct_leader:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:offer_schema:readonly", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "hire:employee", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "im:chat:create_by_user", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "contact:contact.base:readonly", + "final_score": "70.0000", + "recommend": "true" + }, + { + "scope_name": "okr:okr.period:readonly", + "final_score": "71.6425", + "recommend": "false" + }, + { + "scope_name": "hire:exam:readonly", + "final_score": "53.4918", + "recommend": "false" + }, + { + "scope_name": "corehr:signature_file.pre_hire:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "serviceaccount:approval:approvals:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.service_company:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "app_engine:dataset.record:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:ehr_import", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "application:application:patch", + "final_score": "54.7507", + "recommend": "false" + }, + { + "scope_name": "vc:note:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "search:data_source:update", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "hire:job.composite_info:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.department:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.avatar:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.international_assignment.service_company:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "aily:knowledge:ask", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:check_work_email", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "payroll:payment_activity_details:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:workspace.table.record:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "contact:user.base:readonly", + "final_score": "72.4720", + "recommend": "true" + }, + { + "scope_name": "directory:job_title:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:workforce_plan:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_data.assignment_start_reason:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.custom_org_field:read", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.preferred_name:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:device_record:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "document_ai:driving_license:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "hire:job_requirement:readonly", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "mdm:company.company_bank_account.account:readonly", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "hire:interviewer", + "final_score": "58.8295", + "recommend": "false" + }, + { + "scope_name": "performance:semester:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "application:application.feedback.feedback_info", + "final_score": "56.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.workforce_type:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change:write", + "final_score": "51.5638", + "recommend": "false" + }, + { + "scope_name": "event:ip_list", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "helpdesk:helpdesk:access", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.event:update", + "final_score": "53.0430", + "recommend": "false" + }, + { + "scope_name": "corehr:leave_record:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:application:readonly", + "final_score": "53.4918", + "recommend": "false" + }, + { + "scope_name": "app_engine:role:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "corehr:approval_groups.orgdraft_department_change:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:department.custom_field:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "cardkit:template:read", + "final_score": "58.4918", + "recommend": "false" + }, + { + "scope_name": "app_engine:seat_activities:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.nationality:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_item_category:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_level:write", + "final_score": "56.0359", + "recommend": "false" + }, + { + "scope_name": "corehr:job_family:write", + "final_score": "55.0720", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation.recurring_payment:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "docs:document.comment:delete", + "final_score": "68.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:signature.file:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "hire:offer:readonly", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "acs:users", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "board:whiteboard:node:read", + "final_score": "70.6590", + "recommend": "true" + }, + { + "scope_name": "corehr:company:read", + "final_score": "59.4720", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar:read", + "final_score": "53.4918", + "recommend": "false" + }, + { + "scope_name": "corehr:contract:write", + "final_score": "64.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:job_level", + "final_score": "54.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:employee.add:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.is_adjust_salary:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.pathway:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job:read", + "final_score": "60.4359", + "recommend": "false" + }, + { + "scope_name": "corehr:person.resident_tax_custom_field:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.mail_contact:read", + "final_score": "84.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.working_calendar:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "contact:work_city:readonly", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:authorization.bp:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:bp.get_by_department:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "im:message.pins:read", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "task:custom_field:write", + "final_score": "74.0430", + "recommend": "true" + }, + { + "scope_name": "base:field:delete", + "final_score": "71.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:employment:write", + "final_score": "51.5638", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_standards:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.cost_center:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "calendar:timeoff", + "final_score": "61.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:compensation.lump_sum_payment:write", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "wiki:node:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "calendar:calendar:readonly", + "final_score": "62.0000", + "recommend": "true" + }, + { + "scope_name": "im:message.group_at_msg:readonly", + "final_score": "76.9638", + "recommend": "true" + }, + { + "scope_name": "directory:department:search", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.custom_org_field:write", + "final_score": "75.4295", + "recommend": "false" + }, + { + "scope_name": "hire:talent:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.enterprise_email_alias:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:probation.probation_outcome:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "corehr:authorization:write", + "final_score": "62.7507", + "recommend": "false" + }, + { + "scope_name": "corehr:person.date_of_birth:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "approval:approval:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "okr:okr.review:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:out:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "base:form:create", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "corehr:common_data.meta_data:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.address:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.emergency_contact:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "contact:job_level", + "final_score": "46.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.status_update_event:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job_family:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "directory:job_title.status:read", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "corehr:probation.probation_extend_expected_end_date:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.dotted_manager:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "payroll:cost_allocation_details:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_archive_detail.salary_level:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:default_cost_center:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "hire:job_process:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:approval:write", + "final_score": "49.0720", + "recommend": "false" + }, + { + "scope_name": "corehr:person.is_old_alone:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:job", + "final_score": "41.6590", + "recommend": "false" + }, + { + "scope_name": "corehr:person.additional_nationalities:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:read_only", + "final_score": "66.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.job:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.custom_org:read", + "final_score": "79.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.is_resigned:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.service_company:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "report:task:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:withdraw_onboarding", + "final_score": "63.3213", + "recommend": "false" + }, + { + "scope_name": "directory:department:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "hire:employee:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation.recurring_payment:delete", + "final_score": "60.3213", + "recommend": "false" + }, + { + "scope_name": "payroll:pay_groups:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "drive:export:readonly", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "calendar:exchange.bindings:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:event:subscribe", + "final_score": "60.4430", + "recommend": "true" + }, + { + "scope_name": "directory:department.organization:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:contract.period:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_data.job_data_reason:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "task:task:read", + "final_score": "74.4918", + "recommend": "true" + }, + { + "scope_name": "directory:department:write", + "final_score": "56.7705", + "recommend": "false" + }, + { + "scope_name": "document_ai:train_invoice:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "calendar:calendar.acl:create", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "task:comment:read", + "final_score": "82.1705", + "recommend": "true" + }, + { + "scope_name": "directory:department.data_source:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:user.id:readonly", + "final_score": "77.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:person.gender:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.event.mail_address:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:probation.self_review:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "mdm:spend:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "im:tag:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "im:chat.widgets:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "aily:knowledge:write", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "task:tasklist.privilege:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.department:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "payroll:payment_activity:archive", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "okr:okr.period:writeonly", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "auth:user_access_token:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "hire:todo:readonly", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:approval_groups.orgdraft_position_change:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:additional_job.work_shift:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "ehr:employee:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "aily:message:read", + "final_score": "57.4430", + "recommend": "false" + }, + { + "scope_name": "im:message.reactions:read", + "final_score": "82.1705", + "recommend": "true" + }, + { + "scope_name": "base:workflow:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "base:table:create", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "search:data_item:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "attendance:rule", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "document_ai:vat_invoice:recognize", + "final_score": "74.3213", + "recommend": "true" + }, + { + "scope_name": "im:chat:read", + "final_score": "65.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:custom_org:read", + "final_score": "67.1507", + "recommend": "false" + }, + { + "scope_name": "im:chat.menu_tree:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "directory:employee_type_enum:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "base:workflow:create", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "vc:alert:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "calendar:room:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.custom_field:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "mail:mailgroup:readonly", + "final_score": "53.4918", + "recommend": "false" + }, + { + "scope_name": "corehr:job_data.service_company:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employee:write", + "final_score": "46.1853", + "recommend": "false" + }, + { + "scope_name": "directory:department.update:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "aily:session:write", + "final_score": "53.0430", + "recommend": "false" + }, + { + "scope_name": "space:folder:create", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change.pathway:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "base:workflow:delete", + "final_score": "76.3213", + "recommend": "true" + }, + { + "scope_name": "ea_integration_platform:lawfirm_attorney_capacity:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "im:chat.members:write_only", + "final_score": "64.1705", + "recommend": "true" + }, + { + "scope_name": "directory:department.has_child:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_grade:write", + "final_score": "51.5638", + "recommend": "false" + }, + { + "scope_name": "corehr:department.manager:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.department_path:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.compensation_type:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_data:read", + "final_score": "71.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:person.legal_name:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "app_engine:attachment:write", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "docs:event.document_deleted:read", + "final_score": "82.8295", + "recommend": "true" + }, + { + "scope_name": "okr:okr:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "im:message.urgent:sms", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.working_hours_type:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "corehr:person.legal_name:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:corehr:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "contact:user.phone:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.subscription_ids:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "calendar:calendar:update", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.name.last_name:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.is_primary_admin:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.check_in_data:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "performance:review_template:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "im:chat:delete", + "final_score": "76.3213", + "recommend": "true" + }, + { + "scope_name": "performance:semester_activity:read", + "final_score": "55.9638", + "recommend": "false" + }, + { + "scope_name": "corehr:person.resident_tax:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "docs:document:export", + "final_score": "69.1705", + "recommend": "true" + }, + { + "scope_name": "corehr:job_data:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "hire:talent_blocklist", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "hire:external_application", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "report:rule:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:approval_groups:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.contract_end_date:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "base:role:update", + "final_score": "75.2425", + "recommend": "true" + }, + { + "scope_name": "contact:user.user_geo", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.duration_type:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.social_security_city:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.job_number:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "baike:entity:exempt_delete", + "final_score": "55.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_plan_detail.indicators:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:department.custom_fields:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "spark:app.table:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.message.address:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "wiki:member:retrieve", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "calendar:calendar:subscribe", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "aily:run:read", + "final_score": "76.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire:restore_flow_instance", + "final_score": "63.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:department.organize:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "speech_to_text:speech", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "calendar:settings.workhour:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.external_id:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "task:comment:readonly", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:employment.custom_field:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "application:application.app_version", + "final_score": "61.8295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.work.resign_type:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "verification:verification_information:readonly", + "final_score": "92.3213", + "recommend": "true" + }, + { + "scope_name": "directory:department.leader:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.direct_manager:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "calendar:calendar.free_busy:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "attendance:rule:readonly", + "final_score": "49.7643", + "recommend": "false" + }, + { + "scope_name": "hire:referral_website:readonly", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "admin:app.admin_id:readonly", + "final_score": "76.6425", + "recommend": "false" + }, + { + "scope_name": "base:dashboard:update", + "final_score": "82.9213", + "recommend": "true" + }, + { + "scope_name": "corehr:person.passport_number:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.international_assignment.job:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "hire:job:readonly", + "final_score": "50.5853", + "recommend": "false" + }, + { + "scope_name": "wiki:setting:write_only", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "directory:employee.to_be_resigned:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.background_image:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:default_cost_center:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "hire:questionnaire", + "final_score": "58.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:security.audit_log:read", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "mail:user_mailbox.rule:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:contact", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "corehr:process.instance:write", + "final_score": "56.5638", + "recommend": "false" + }, + { + "scope_name": "task:comment", + "final_score": "62.1507", + "recommend": "true" + }, + { + "scope_name": "corehr:leave_grant:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.leader_id:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "corehr:additional_job.service_company:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "contact:unit", + "final_score": "46.1507", + "recommend": "false" + }, + { + "scope_name": "base:record:retrieve", + "final_score": "66.6425", + "recommend": "true" + }, + { + "scope_name": "im:chat.widgets:write_only", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "directory:department.department_path:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "attendance_machine:users", + "final_score": "50.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:person.bank_account:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "base:history:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "admin:app.info:readonly", + "final_score": "57.0000", + "recommend": "false" + }, + { + "scope_name": "app_engine:application.environment_variable:read", + "final_score": "74.8295", + "recommend": "false" + }, + { + "scope_name": "task:section:write", + "final_score": "64.0359", + "recommend": "true" + }, + { + "scope_name": "corehr:job_change:read", + "final_score": "61.1705", + "recommend": "false" + }, + { + "scope_name": "hire:note:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:compensation_archive_detail.indicators:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:pre_hire.onboarding_address:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:contract.company:read", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "directory:department.external_id:read", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "attendance:overtime_approval:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.citizenship_status:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "search:dataset.docs:delete", + "final_score": "63.8295", + "recommend": "false" + }, + { + "scope_name": "corehr:leave:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "admin:app.visibility", + "final_score": "56.1705", + "recommend": "false" + }, + { + "scope_name": "mdm:spend", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "hire:talent", + "final_score": "36.0000", + "recommend": "false" + }, + { + "scope_name": "im:message:send_as_bot", + "final_score": "57.0000", + "recommend": "true" + }, + { + "scope_name": "corehr:person.martyr_family:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_family:read", + "final_score": "62.6590", + "recommend": "false" + }, + { + "scope_name": "contact:job_family", + "final_score": "46.1507", + "recommend": "false" + }, + { + "scope_name": "base:app:update", + "final_score": "70.4295", + "recommend": "true" + }, + { + "scope_name": "directory:employee.idconvert:read", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "drive:drive:version", + "final_score": "62.1507", + "recommend": "true" + }, + { + "scope_name": "corehr:person.hukou:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "contact:user.department:readonly", + "final_score": "53.4918", + "recommend": "false" + }, + { + "scope_name": "base:form:read", + "final_score": "79.6425", + "recommend": "true" + }, + { + "scope_name": "im:message:recall", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:person.custom_field:write", + "final_score": "62.4295", + "recommend": "false" + }, + { + "scope_name": "security_and_compliance:multi_geo_entity.tenant:readonly", + "final_score": "76.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:common_data.meta_data:read", + "final_score": "67.1507", + "recommend": "false" + }, + { + "scope_name": "corehr:position:read", + "final_score": "59.1507", + "recommend": "false" + }, + { + "scope_name": "hire:referral", + "final_score": "50.6425", + "recommend": "false" + }, + { + "scope_name": "okr:okr.content:readonly", + "final_score": "60.4359", + "recommend": "false" + }, + { + "scope_name": "search:dataset:delete", + "final_score": "68.3213", + "recommend": "false" + }, + { + "scope_name": "corehr:common_data.basic_data:read", + "final_score": "57.0000", + "recommend": "false" + }, + { + "scope_name": "hire:location:readonly", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:job_change.working_hours_type:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "personal_settings:status:system_status_operate", + "final_score": "53.8295", + "recommend": "false" + }, + { + "scope_name": "financial_access_platform:data:write", + "final_score": "54.7507", + "recommend": "false" + }, + { + "scope_name": "corehr:department:read", + "final_score": "51.4720", + "recommend": "false" + }, + { + "scope_name": "hire:referral_account", + "final_score": "44.4430", + "recommend": "false" + }, + { + "scope_name": "base:form:update", + "final_score": "72.7705", + "recommend": "true" + }, + { + "scope_name": "corehr:person.national_id:write", + "final_score": "70.4295", + "recommend": "false" + }, + { + "scope_name": "wiki:node:create", + "final_score": "78.4295", + "recommend": "true" + }, + { + "scope_name": "block:entity", + "final_score": "74.8295", + "recommend": "true" + }, + { + "scope_name": "docs:event.document_opened:read", + "final_score": "87.3213", + "recommend": "true" + }, + { + "scope_name": "workplace:workplace_using_data:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "directory:employee.update:write", + "final_score": "66.9213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.mobile:read", + "final_score": "63.6425", + "recommend": "false" + }, + { + "scope_name": "corehr:pathway:read", + "final_score": "69.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:employment.pay_group:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "directory:employee.base.dept_order:read", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "drive:drive:readonly", + "final_score": "49.0000", + "recommend": "false" + }, + { + "scope_name": "app_engine:data.record.change:read", + "final_score": "66.8295", + "recommend": "false" + }, + { + "scope_name": "hire:talent_blocklist:readonly", + "final_score": "71.3213", + "recommend": "false" + }, + { + "scope_name": "app_engine:workspace.sql_commands:write", + "final_score": "74.9213", + "recommend": "false" + }, + { + "scope_name": "corehr:job_level:readonly", + "final_score": "79.3213", + "recommend": "false" + }, + { + "scope_name": "aily:skill:read", + "final_score": "87.8295", + "recommend": "true" + }, + { + "scope_name": "slides:presentation:read", + "final_score": "71.6425", + "recommend": "true" + }, + { + "scope_name": "trust_party:collaboration_rule:write", + "final_score": "59.2425", + "recommend": "false" + }, + { + "scope_name": "space:document:move", + "final_score": "69.8295", + "recommend": "true" + }, + { + "scope_name": "directory:employee.work.extension_number:read", + "final_score": "79.3213", + "recommend": "true" + }, + { + "scope_name": "hire:attachment", + "final_score": "48.1705", + "recommend": "false" + }, + { + "scope_name": "corehr:offboarding.revoke:write", + "final_score": "66.9213", + "recommend": "false" + } +] diff --git a/internal/registry/scopes.go b/internal/registry/scopes.go new file mode 100644 index 00000000..22678b6f --- /dev/null +++ b/internal/registry/scopes.go @@ -0,0 +1,384 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + "sort" + "strings" +) + +// IdentityToAccessToken maps the --identity flag value to the corresponding +// accessTokens value used in from_meta JSON files. Bot identity uses +// tenant_access_token, so "bot" maps to "tenant". +func IdentityToAccessToken(identity string) string { + if identity == "bot" { + return "tenant" + } + return identity +} + +// FilterScopes filters scopes by domain and permission level. +func FilterScopes(allScopes []string, domains []string, permissions []string) []string { + var result []string + for _, scope := range allScopes { + parts := strings.Split(scope, ":") + + if len(domains) > 0 { + if len(parts) == 0 { + continue + } + found := false + for _, d := range domains { + if parts[0] == d { + found = true + break + } + } + if !found { + continue + } + } + + if len(permissions) > 0 { + if len(parts) < 3 { + continue + } + perm := parts[2] + matched := false + for _, p := range permissions { + switch p { + case "read": + if strings.Contains(perm, "read") { + matched = true + } + case "write": + if strings.Contains(perm, "write") { + matched = true + } + case "readonly": + if perm == "readonly" { + matched = true + } + case "writeonly": + if perm == "writeonly" || perm == "write_only" { + matched = true + } + } + } + if !matched { + continue + } + } + + result = append(result, scope) + } + return result +} + +// CollectScopesForProjects collects the recommended scope for each API method +// in the specified from_meta projects. For each method, only the scope with +// the highest priority score is selected. +func CollectScopesForProjects(projects []string, identity string) []string { + priorities := LoadScopePriorities() + scopeSet := make(map[string]bool) + for _, project := range projects { + spec := LoadFromMeta(project) + if spec == nil { + continue + } + resources, ok := spec["resources"].(map[string]interface{}) + if !ok { + continue + } + for _, resSpec := range resources { + resMap, ok := resSpec.(map[string]interface{}) + if !ok { + continue + } + methods, ok := resMap["methods"].(map[string]interface{}) + if !ok { + continue + } + for _, methodSpec := range methods { + methodMap, ok := methodSpec.(map[string]interface{}) + if !ok { + continue + } + if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { + supported := false + for _, t := range tokens { + if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { + supported = true + break + } + } + if !supported { + continue + } + } + scopes, ok := methodMap["scopes"].([]interface{}) + if !ok || len(scopes) == 0 { + continue + } + bestScope := "" + bestScore := -1 + for _, s := range scopes { + str, ok := s.(string) + if !ok { + continue + } + score := DefaultScopeScore + if v, exists := priorities[str]; exists { + score = v + } + if score > bestScore { + bestScore = score + bestScope = str + } + } + if bestScope != "" { + scopeSet[bestScope] = true + } + } + } + } + + result := make([]string, 0, len(scopeSet)) + for s := range scopeSet { + result = append(result, s) + } + sort.Strings(result) + return result +} + +// ScopeSource tracks which APIs and shortcuts contributed a scope. +type ScopeSource struct { + APIs []string // e.g. "POST calendar.event.create" + Shortcuts []string // e.g. "+send", "+reply" +} + +// CollectScopesWithSources is like CollectScopesForProjects but also records +// which API method contributed each scope. Used by scope-audit. +func CollectScopesWithSources(projects []string, identity string) ([]string, map[string]*ScopeSource) { + priorities := LoadScopePriorities() + scopeSet := make(map[string]bool) + sources := make(map[string]*ScopeSource) + + for _, project := range projects { + spec := LoadFromMeta(project) + if spec == nil { + continue + } + resources, ok := spec["resources"].(map[string]interface{}) + if !ok { + continue + } + for resName, resSpec := range resources { + resMap, ok := resSpec.(map[string]interface{}) + if !ok { + continue + } + methods, ok := resMap["methods"].(map[string]interface{}) + if !ok { + continue + } + for methodName, methodSpec := range methods { + methodMap, ok := methodSpec.(map[string]interface{}) + if !ok { + continue + } + if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { + supported := false + for _, t := range tokens { + if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { + supported = true + break + } + } + if !supported { + continue + } + } + scopes, ok := methodMap["scopes"].([]interface{}) + if !ok || len(scopes) == 0 { + continue + } + bestScope := "" + bestScore := -1 + for _, s := range scopes { + str, ok := s.(string) + if !ok { + continue + } + score := DefaultScopeScore + if v, exists := priorities[str]; exists { + score = v + } + if score > bestScore { + bestScore = score + bestScope = str + } + } + if bestScope != "" { + scopeSet[bestScope] = true + if sources[bestScope] == nil { + sources[bestScope] = &ScopeSource{} + } + methodID := GetStrFromMap(methodMap, "id") + if methodID == "" { + methodID = project + "." + resName + "." + methodName + } + httpMethod := GetStrFromMap(methodMap, "httpMethod") + if httpMethod == "" { + httpMethod = "?" + } + sources[bestScope].APIs = append(sources[bestScope].APIs, httpMethod+" "+methodID) + } + } + } + } + + // Sort API lists for stable output + for _, src := range sources { + sort.Strings(src.APIs) + } + + result := make([]string, 0, len(scopeSet)) + for s := range scopeSet { + result = append(result, s) + } + sort.Strings(result) + return result, sources +} + +// CommandEntry represents a CLI command (API method or shortcut) and its scopes. +type CommandEntry struct { + Command string // CLI label, e.g. "calendars create" or "+agenda" + Type string // "api" or "shortcut" + Scopes []string // effective scopes (requiredScopes if present, else [bestScope]) + HTTPMethod string // e.g. "POST" (API only) +} + +// CollectCommandScopes walks from_meta methods for the given projects and +// returns one CommandEntry per API method, sorted by command label. +// +// Scope selection per method: +// - If the method has a "requiredScopes" field, all of those scopes are needed (conjunction). +// - Otherwise, only the highest-priority scope from "scopes" is shown (minimum privilege). +func CollectCommandScopes(projects []string, identity string) []CommandEntry { + priorities := LoadScopePriorities() + var entries []CommandEntry + + for _, project := range projects { + spec := LoadFromMeta(project) + if spec == nil { + continue + } + resources, ok := spec["resources"].(map[string]interface{}) + if !ok { + continue + } + for resName, resSpec := range resources { + resMap, ok := resSpec.(map[string]interface{}) + if !ok { + continue + } + methods, ok := resMap["methods"].(map[string]interface{}) + if !ok { + continue + } + for methodName, methodSpec := range methods { + methodMap, ok := methodSpec.(map[string]interface{}) + if !ok { + continue + } + if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { + supported := false + for _, t := range tokens { + if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { + supported = true + break + } + } + if !supported { + continue + } + } + rawScopes, ok := methodMap["scopes"].([]interface{}) + if !ok || len(rawScopes) == 0 { + continue + } + + // Check for requiredScopes (conjunction — all needed) + var effectiveScopes []string + if reqRaw, ok := methodMap["requiredScopes"].([]interface{}); ok && len(reqRaw) > 0 { + for _, s := range reqRaw { + if str, ok := s.(string); ok { + effectiveScopes = append(effectiveScopes, str) + } + } + } else { + // Pick the single best scope (minimum privilege) + bestScope := "" + bestScore := -1 + for _, s := range rawScopes { + str, ok := s.(string) + if !ok { + continue + } + score := DefaultScopeScore + if v, exists := priorities[str]; exists { + score = v + } + if score > bestScore { + bestScore = score + bestScope = str + } + } + if bestScope != "" { + effectiveScopes = []string{bestScope} + } + } + if len(effectiveScopes) == 0 { + continue + } + + httpMethod := GetStrFromMap(methodMap, "httpMethod") + entries = append(entries, CommandEntry{ + Command: resName + " " + methodName, + Type: "api", + Scopes: effectiveScopes, + HTTPMethod: httpMethod, + }) + } + } + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Command < entries[j].Command + }) + return entries +} + +// GetScopesForDomains returns scopes for specific projects (by project name). +func GetScopesForDomains(projects []string, identity string) []string { + return CollectScopesForProjects(projects, identity) +} + +// GetReadOnlyScopes returns read-only scopes from the recommended (best-per-method) scope set. +func GetReadOnlyScopes(identity string) []string { + allProjects := ListFromMetaProjects() + return FilterScopes(CollectScopesForProjects(allProjects, identity), nil, []string{"read", "readonly"}) +} + +// ResolveScopesFromFilters resolves scopes from project and permission filters. +func ResolveScopesFromFilters(projects []string, permissions []string, identity string) []string { + return FilterScopes(CollectScopesForProjects(projects, identity), nil, permissions) +} + +// ComputeMinimumScopeSet computes the minimum set of scopes that covers all +// from_meta API methods. Equivalent to CollectScopesForProjects with all projects. +func ComputeMinimumScopeSet(identity string) []string { + return CollectScopesForProjects(ListFromMetaProjects(), identity) +} diff --git a/internal/registry/service_desc.go b/internal/registry/service_desc.go new file mode 100644 index 00000000..ae644bf5 --- /dev/null +++ b/internal/registry/service_desc.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import ( + _ "embed" + "encoding/json" +) + +//go:embed service_descriptions.json +var serviceDescJSON []byte + +// serviceDescLocale holds title and description for one language. +type serviceDescLocale struct { + Title string `json:"title"` + Description string `json:"description"` +} + +// serviceDescEntry holds bilingual descriptions for a service domain. +type serviceDescEntry struct { + En serviceDescLocale `json:"en"` + Zh serviceDescLocale `json:"zh"` +} + +var serviceDescMap map[string]serviceDescEntry + +func loadServiceDescriptions() map[string]serviceDescEntry { + if serviceDescMap != nil { + return serviceDescMap + } + serviceDescMap = make(map[string]serviceDescEntry) + _ = json.Unmarshal(serviceDescJSON, &serviceDescMap) + return serviceDescMap +} + +func getServiceLocale(name, lang string) *serviceDescLocale { + m := loadServiceDescriptions() + entry, ok := m[name] + if !ok { + return nil + } + if lang == "en" { + return &entry.En + } + return &entry.Zh +} + +// GetServiceDescription returns the localized description for a service domain, +// suitable for --help output. Returns the description field directly. +// Returns empty string if not found in the config. +func GetServiceDescription(name, lang string) string { + loc := getServiceLocale(name, lang) + if loc == nil { + return "" + } + return loc.Description +} + +// GetServiceTitle returns the localized title for a service domain. +// Returns empty string if not found. +func GetServiceTitle(name, lang string) string { + loc := getServiceLocale(name, lang) + if loc == nil { + return "" + } + return loc.Title +} + +// GetServiceDetailDescription returns the localized detail description for a service domain. +// Returns empty string if not found. +func GetServiceDetailDescription(name, lang string) string { + loc := getServiceLocale(name, lang) + if loc == nil { + return "" + } + return loc.Description +} diff --git a/internal/registry/service_descriptions.json b/internal/registry/service_descriptions.json new file mode 100644 index 00000000..2ac898b8 --- /dev/null +++ b/internal/registry/service_descriptions.json @@ -0,0 +1,58 @@ +{ + "base": { + "en": { "title": "Base", "description": "Table, field, record, and view management" }, + "zh": { "title": "多维表格", "description": "数据表、字段、记录、视图" } + }, + "calendar": { + "en": { "title": "Calendar", "description": "Calendar, event, and attendee management" }, + "zh": { "title": "日历", "description": "日程、日历、参会人管理" } + }, + "contact": { + "en": { "title": "Contacts", "description": "Contacts operations" }, + "zh": { "title": "通讯录", "description": "用户查询、通讯录搜索" } + }, + "docs": { + "en": { "title": "Docs", "description": "Document and content operations" }, + "zh": { "title": "文档", "description": "文档创建、编辑、搜索" } + }, + "drive": { + "en": { "title": "Drive", "description": "File, comment, permission, and upload management" }, + "zh": { "title": "云空间", "description": "文件管理、文档评论、素材上传下载、文档权限管理" } + }, + "event": { + "en": { "title": "Event", "description": "Event subscription management" }, + "zh": { "title": "事件订阅", "description": "WebSocket 实时推送" } + }, + "im": { + "en": { "title": "Messenger", "description": "Message and group chat management" }, + "zh": { "title": "消息与群组", "description": "消息发送、群聊管理" } + }, + "mail": { + "en": { "title": "Mail", "description": "Email, draft, folder, and contacts management" }, + "zh": { "title": "邮箱", "description": "查看和管理用户邮箱数据,包括邮件、草稿、文件夹和联系人" } + }, + "minutes": { + "en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" }, + "zh": { "title": "妙记", "description": "妙记信息获取、内容查询" } + }, + "sheets": { + "en": { "title": "Sheets", "description": "Spreadsheet operations" }, + "zh": { "title": "电子表格", "description": "电子表格操作" } + }, + "task": { + "en": { "title": "Task", "description": "Task, task list, and subtask management" }, + "zh": { "title": "任务", "description": "任务、清单、子任务管理" } + }, + "vc": { + "en": { "title": "VC", "description": "Video conference and meeting note management" }, + "zh": { "title": "视频会议", "description": "视频会议与会议纪要管理" } + }, + "whiteboard": { + "en": { "title": "Whiteboard", "description": "Create and edit boards" }, + "zh": { "title": "画板", "description": "画板创建、编辑" } + }, + "wiki": { + "en": { "title": "Wiki", "description": "Wiki space and node management" }, + "zh": { "title": "知识库", "description": "知识空间、节点管理" } + } +} diff --git a/internal/util/json.go b/internal/util/json.go new file mode 100644 index 00000000..8543cbf6 --- /dev/null +++ b/internal/util/json.go @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package util + +import ( + "encoding/json" +) + +// ToFloat64 extracts a float64 from a value that may be float64 or json.Number. +// Returns (0, false) if the value is neither. +func ToFloat64(v interface{}) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case json.Number: + f, err := n.Float64() + return f, err == nil + case int: + return float64(n), true + case int64: + return float64(n), true + } + return 0, false +} diff --git a/internal/util/reflect.go b/internal/util/reflect.go new file mode 100644 index 00000000..5589dfe9 --- /dev/null +++ b/internal/util/reflect.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package util + +import "reflect" + +// IsNil reports whether v is nil, covering both untyped nil (interface itself) +// and typed nil (e.g. (*T)(nil) wrapped in interface{}). +// Avoids direct interface{} == nil comparison . +func IsNil(v interface{}) bool { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return true + } + switch rv.Kind() { + case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Func, reflect.Interface, reflect.Chan: + return rv.IsNil() + default: + return false + } +} + +// IsEmptyValue checks whether v is considered empty using reflect. +// Returns true for nil interface, and zero values of the underlying type +// (e.g. "", 0, false, empty slice/map). +func IsEmptyValue(v interface{}) bool { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return true + } + return rv.IsZero() +} diff --git a/internal/util/reflect_test.go b/internal/util/reflect_test.go new file mode 100644 index 00000000..16827443 --- /dev/null +++ b/internal/util/reflect_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package util + +import "testing" + +func TestIsNil(t *testing.T) { + var nilPtr *int + var nilSlice []int + var nilMap map[string]int + var nilChan chan int + var nilFunc func() + nonNilPtr := new(int) + + tests := []struct { + name string + v interface{} + want bool + }{ + {"nil", nil, true}, + {"empty string", "", false}, + {"zero int", 0, false}, + {"false", false, false}, + {"non-nil map", map[string]interface{}{}, false}, + {"non-nil slice", []interface{}{}, false}, + {"string value", "hello", false}, + {"typed-nil pointer", nilPtr, true}, + {"typed-nil slice", nilSlice, true}, + {"typed-nil map", nilMap, true}, + {"typed-nil chan", nilChan, true}, + {"typed-nil func", nilFunc, true}, + {"non-nil pointer", nonNilPtr, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsNil(tt.v); got != tt.want { + t.Errorf("IsNil(%v) = %v, want %v", tt.v, got, tt.want) + } + }) + } +} + +func TestIsEmptyValue(t *testing.T) { + tests := []struct { + name string + v interface{} + want bool + }{ + {"nil", nil, true}, + {"empty string", "", true}, + {"non-empty string", "hello", false}, + {"zero int", 0, true}, + {"non-zero int", 42, false}, + {"zero float64", float64(0), true}, + {"non-zero float64", float64(3.14), false}, + {"false", false, true}, + {"true", true, false}, + {"nil slice", []interface{}(nil), true}, + {"empty slice", []interface{}{}, false}, + {"non-empty slice", []interface{}{1}, false}, + {"nil map", map[string]interface{}(nil), true}, + {"empty map", map[string]interface{}{}, false}, + {"non-empty map", map[string]interface{}{"a": 1}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsEmptyValue(tt.v); got != tt.want { + t.Errorf("IsEmptyValue(%v) = %v, want %v", tt.v, got, tt.want) + } + }) + } +} diff --git a/internal/util/strings.go b/internal/util/strings.go new file mode 100644 index 00000000..0850a607 --- /dev/null +++ b/internal/util/strings.go @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package util + +// TruncateStr truncates s to at most n runes, safe for multi-byte (e.g. CJK) characters. +func TruncateStr(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} + +// TruncateStrWithEllipsis truncates s to at most n runes (including "..." suffix). +func TruncateStrWithEllipsis(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + if n < 3 { + return string(r[:n]) + } + return string(r[:n-3]) + "..." +} diff --git a/internal/validate/atomicwrite.go b/internal/validate/atomicwrite.go new file mode 100644 index 00000000..8bd5b471 --- /dev/null +++ b/internal/validate/atomicwrite.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// AtomicWrite writes data to path atomically by creating a temp file in the +// same directory, writing and fsyncing the data, then renaming over the target. +// It replaces os.WriteFile for all config and download file writes. +// +// os.WriteFile truncates the target before writing, so a process kill (CI timeout, +// OOM, Ctrl+C) between truncate and completion leaves the file empty or partial. +// AtomicWrite avoids this: on any failure the temp file is cleaned up and the +// original file remains untouched. +func AtomicWrite(path string, data []byte, perm os.FileMode) error { + return atomicWrite(path, perm, func(tmp *os.File) error { + _, err := tmp.Write(data) + return err + }) +} + +// AtomicWriteFromReader atomically copies reader contents into path. +func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) { + var copied int64 + err := atomicWrite(path, perm, func(tmp *os.File) error { + n, err := io.Copy(tmp, reader) + copied = n + return err + }) + if err != nil { + return 0, err + } + return copied, nil +} + +func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpName := tmp.Name() + + success := false + defer func() { + if !success { + tmp.Close() + os.Remove(tmpName) + } + }() + + if err := tmp.Chmod(perm); err != nil { + return err + } + if err := writeFn(tmp); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, path); err != nil { + return err + } + success = true + return nil +} diff --git a/internal/validate/atomicwrite_test.go b/internal/validate/atomicwrite_test.go new file mode 100644 index 00000000..b4e328b0 --- /dev/null +++ b/internal/validate/atomicwrite_test.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "os" + "path/filepath" + "runtime" + "sync" + "testing" +) + +func TestAtomicWrite_WritesContentAndPermissionCorrectly(t *testing.T) { + // GIVEN: a target path in a temp directory + dir := t.TempDir() + path := filepath.Join(dir, "test.json") + data := []byte(`{"key":"value"}`) + + // WHEN: AtomicWrite writes data with 0644 permission + if err := AtomicWrite(path, data, 0644); err != nil { + t.Fatalf("AtomicWrite failed: %v", err) + } + + // THEN: file content matches exactly + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if string(got) != string(data) { + t.Errorf("content = %q, want %q", got, data) + } +} + +func TestAtomicWrite_SetsRestrictivePermission(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("permission test not reliable on Windows") + } + + // GIVEN: a target path + dir := t.TempDir() + path := filepath.Join(dir, "secret.json") + + // WHEN: AtomicWrite writes with 0600 permission + if err := AtomicWrite(path, []byte("secret"), 0600); err != nil { + t.Fatalf("AtomicWrite failed: %v", err) + } + + // THEN: file permission is exactly 0600 (owner read-write only) + info, _ := os.Stat(path) + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("permission = %04o, want 0600", perm) + } +} + +func TestAtomicWrite_OverwritesExistingFile(t *testing.T) { + // GIVEN: an existing file with old content + dir := t.TempDir() + path := filepath.Join(dir, "test.json") + AtomicWrite(path, []byte("old"), 0644) + + // WHEN: AtomicWrite overwrites with new content + if err := AtomicWrite(path, []byte("new"), 0644); err != nil { + t.Fatalf("second write failed: %v", err) + } + + // THEN: file contains new content + got, _ := os.ReadFile(path) + if string(got) != "new" { + t.Errorf("content = %q, want %q", got, "new") + } +} + +func TestAtomicWrite_LeavesNoResidualTempFileOnError(t *testing.T) { + // GIVEN: a target path in a non-existent nested directory + path := filepath.Join(t.TempDir(), "nonexistent", "subdir", "file.txt") + + // WHEN: AtomicWrite fails (parent directory doesn't exist) + err := AtomicWrite(path, []byte("data"), 0644) + + // THEN: the write fails + if err == nil { + t.Fatal("expected error writing to nonexistent dir") + } + + // THEN: no .tmp files are left behind + parentDir := filepath.Dir(filepath.Dir(path)) + entries, _ := os.ReadDir(parentDir) + for _, e := range entries { + if filepath.Ext(e.Name()) == ".tmp" { + t.Errorf("residual temp file found: %s", e.Name()) + } + } +} + +func TestAtomicWrite_PreservesOriginalFileOnFailure(t *testing.T) { + // GIVEN: an existing file with known content + dir := t.TempDir() + original := []byte("original content") + path := filepath.Join(dir, "file.json") + if err := AtomicWrite(path, original, 0644); err != nil { + t.Fatal(err) + } + + // WHEN: AtomicWrite targets a non-existent directory (guaranteed to fail even as root) + badPath := filepath.Join(dir, "no", "such", "dir", "file.json") + err := AtomicWrite(badPath, []byte("new"), 0644) + + // THEN: write fails + if err == nil { + t.Fatal("expected error writing to non-existent dir") + } + + // THEN: the original file at the valid path is untouched + got, _ := os.ReadFile(path) + if string(got) != string(original) { + t.Errorf("original file corrupted: got %q, want %q", got, original) + } +} + +func TestAtomicWrite_HandlesCorrectlyUnderConcurrentWrites(t *testing.T) { + // GIVEN: a target file that will be written by 20 concurrent goroutines + dir := t.TempDir() + path := filepath.Join(dir, "concurrent.json") + + // WHEN: 20 goroutines write simultaneously + var wg sync.WaitGroup + for i := range 20 { + wg.Add(1) + go func(n int) { + defer wg.Done() + data := []byte(`{"n":` + string(rune('0'+n%10)) + `}`) + AtomicWrite(path, data, 0644) + }(i) + } + wg.Wait() + + // THEN: file exists and is valid (not corrupted by interleaved writes) + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + if len(got) == 0 { + t.Error("file is empty after concurrent writes") + } +} diff --git a/internal/validate/input.go b/internal/validate/input.go new file mode 100644 index 00000000..0213fa67 --- /dev/null +++ b/internal/validate/input.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "fmt" + "strings" +) + +// RejectControlChars rejects C0 control characters (except \t and \n) and +// dangerous Unicode characters from user input. +// +// Control characters cause subtle security issues: null bytes truncate strings +// at the C layer, \r\n enables HTTP header injection +// Unicode characters allow visual spoofing (e.g. making "report.exe" display +// as "report.txt"). +func RejectControlChars(value, flagName string) error { + for _, r := range value { + if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) { + return fmt.Errorf("%s contains invalid control characters", flagName) + } + if isDangerousUnicode(r) { + return fmt.Errorf("%s contains dangerous Unicode characters", flagName) + } + } + return nil +} + +// RejectCRLF rejects strings containing carriage return (\r) or line feed (\n). +// These characters enable MIME/HTTP header injection and must never appear in +// header field names, values, Content-ID, or filename parameters. +func RejectCRLF(value, fieldName string) error { + if strings.ContainsAny(value, "\r\n") { + return fmt.Errorf("%s contains invalid line break characters", fieldName) + } + return nil +} + +// StripQueryFragment removes any ?query or #fragment suffix from a URL path. +// API parameters must go through structured --params flags, not embedded in +// the path, to prevent parameter injection and behaviour confusion. +func StripQueryFragment(path string) string { + for i := 0; i < len(path); i++ { + if path[i] == '?' || path[i] == '#' { + return path[:i] + } + } + return path +} + +// isDangerousUnicode identifies Unicode code points used for visual spoofing attacks. +// These characters are invisible or alter text direction, allowing attackers to make +// "report.exe" display as "report.txt" (Bidi override) or insert hidden content +// (zero-width characters). +func isDangerousUnicode(r rune) bool { + switch { + case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner + return true + case r == 0xFEFF: // BOM / ZWNBSP + return true + case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO + return true + case r >= 0x2028 && r <= 0x2029: // line/paragraph separator + return true + case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI + return true + } + return false +} diff --git a/internal/validate/input_test.go b/internal/validate/input_test.go new file mode 100644 index 00000000..cb08087f --- /dev/null +++ b/internal/validate/input_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "testing" +) + +func TestRejectControlChars_FiltersControlCharsAndDangerousUnicode(t *testing.T) { + for _, tt := range []struct { + name string + input string + wantErr bool + }{ + // ── GIVEN: normal text → THEN: allowed ── + {"plain text", "hello world", false}, + {"with tab", "hello\tworld", false}, + {"with newline", "hello\nworld", false}, + {"unicode text", "你好世界", false}, + {"with symbols", "hello!@#$^&*()", false}, + {"empty", "", false}, + + // ── GIVEN: C0 control characters → THEN: rejected ── + {"null byte", "hello\x00world", true}, + {"bell", "hello\x07world", true}, + {"backspace", "hello\x08world", true}, + {"escape", "hello\x1bworld", true}, + {"carriage return", "hello\rworld", true}, + {"form feed", "hello\x0cworld", true}, + {"vertical tab", "hello\x0bworld", true}, + {"DEL", "hello\x7fworld", true}, + + // ── GIVEN: dangerous Unicode characters → THEN: rejected ── + {"zero width space", "hello\u200Bworld", true}, + {"zero width non-joiner", "hello\u200Cworld", true}, + {"zero width joiner", "hello\u200Dworld", true}, + {"BOM", "hello\uFEFFworld", true}, + {"bidi LRE", "hello\u202Aworld", true}, + {"bidi RLE", "hello\u202Bworld", true}, + {"bidi PDF", "hello\u202Cworld", true}, + {"bidi LRO", "hello\u202Dworld", true}, + {"bidi RLO", "hello\u202Eworld", true}, + {"line separator", "hello\u2028world", true}, + {"paragraph separator", "hello\u2029world", true}, + {"bidi LRI", "hello\u2066world", true}, + {"bidi RLI", "hello\u2067world", true}, + {"bidi FSI", "hello\u2068world", true}, + {"bidi PDI", "hello\u2069world", true}, + } { + t.Run(tt.name, func(t *testing.T) { + // WHEN: RejectControlChars validates the input + err := RejectControlChars(tt.input, "--test") + + // THEN: error matches expectation + if (err != nil) != tt.wantErr { + t.Errorf("RejectControlChars(%q) error = %v, wantErr %v", + tt.input, err, tt.wantErr) + } + }) + } +} + +func TestStripQueryFragment(t *testing.T) { + for _, tt := range []struct { + name string + in string + want string + }{ + {"no query or fragment", "/open-apis/test", "/open-apis/test"}, + {"query only", "/open-apis/test?admin=true", "/open-apis/test"}, + {"fragment only", "/open-apis/test#section", "/open-apis/test"}, + {"query and fragment", "/open-apis/test?a=1#frag", "/open-apis/test"}, + {"empty string", "", ""}, + {"query at start", "?foo=bar", ""}, + {"fragment at start", "#frag", ""}, + } { + t.Run(tt.name, func(t *testing.T) { + got := StripQueryFragment(tt.in) + if got != tt.want { + t.Errorf("StripQueryFragment(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/validate/path.go b/internal/validate/path.go new file mode 100644 index 00000000..f9974cf8 --- /dev/null +++ b/internal/validate/path.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// SafeOutputPath validates a download/export target path for --output flags. +// It rejects absolute paths, resolves symlinks to their real location, and +// verifies the canonical result is still under the current working directory. +// This prevents an AI Agent from being tricked into writing files outside the +// working directory (e.g. "../../.ssh/authorized_keys") or following symlinks +// to sensitive locations. +// +// The returned absolute path MUST be used for all subsequent I/O to prevent +// time-of-check-to-time-of-use (TOCTOU) race conditions. +func SafeOutputPath(path string) (string, error) { + return safePath(path, "--output") +} + +// SafeInputPath validates an upload/read source path for --file flags. +// It applies the same rules as SafeOutputPath — rejecting absolute paths, +// resolving symlinks, and enforcing working directory containment — to prevent an AI Agent +// from being tricked into reading sensitive files like /etc/passwd. +func SafeInputPath(path string) (string, error) { + return safePath(path, "--file") +} + +// SafeLocalFlagPath validates a flag value as a local file path. +// Empty values and http/https URLs are returned unchanged without validation, +// allowing the caller to handle non-path inputs (e.g. API keys, URLs) upstream. +// For all other values, SafeInputPath rules apply. +// The original relative path is returned unchanged (not resolved to absolute) so +// upload helpers can re-validate at the actual I/O point via SafeUploadPath. +func SafeLocalFlagPath(flagName, value string) (string, error) { + if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { + return value, nil + } + if _, err := SafeInputPath(value); err != nil { + return "", fmt.Errorf("%s: %v", flagName, err) + } + return value, nil +} + +// safePath is the shared implementation for SafeOutputPath and SafeInputPath. +func safePath(raw, flagName string) (string, error) { + if err := RejectControlChars(raw, flagName); err != nil { + return "", err + } + + path := filepath.Clean(raw) + + if filepath.IsAbs(path) { + return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw) + } + + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("cannot determine working directory: %w", err) + } + resolved := filepath.Join(cwd, path) + + // Resolve symlinks: for existing paths, follow to real location; + // for non-existing paths, walk up to the nearest existing ancestor, + // resolve its symlinks, and re-attach the remaining tail segments. + // This prevents TOCTOU attacks where a non-existent intermediate + // directory is replaced with a symlink between check and use. + if _, err := os.Lstat(resolved); err == nil { + resolved, err = filepath.EvalSymlinks(resolved) + if err != nil { + return "", fmt.Errorf("cannot resolve symlinks: %w", err) + } + } else { + resolved, err = resolveNearestAncestor(resolved) + if err != nil { + return "", fmt.Errorf("cannot resolve symlinks: %w", err) + } + } + + canonicalCwd, _ := filepath.EvalSymlinks(cwd) + if !isUnderDir(resolved, canonicalCwd) { + return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw) + } + + return resolved, nil +} + +// resolveNearestAncestor walks up from path until it finds an existing +// ancestor, resolves that ancestor's symlinks, and re-joins the tail. +// This ensures even deeply nested non-existent paths are anchored to a +// real filesystem location, closing the TOCTOU symlink gap. +func resolveNearestAncestor(path string) (string, error) { + var tail []string + cur := path + for { + if _, err := os.Lstat(cur); err == nil { + real, err := filepath.EvalSymlinks(cur) + if err != nil { + return "", err + } + parts := append([]string{real}, tail...) + return filepath.Join(parts...), nil + } + parent := filepath.Dir(cur) + if parent == cur { + // Reached filesystem root without finding an existing ancestor; + // return path as-is and let the containment check reject it. + parts := append([]string{cur}, tail...) + return filepath.Join(parts...), nil + } + tail = append([]string{filepath.Base(cur)}, tail...) + cur = parent + } +} + +// isUnderDir checks whether child is under parent directory. +func isUnderDir(child, parent string) bool { + rel, err := filepath.Rel(parent, child) + if err != nil { + return false + } + return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." +} diff --git a/internal/validate/path_test.go b/internal/validate/path_test.go new file mode 100644 index 00000000..bc6b1f48 --- /dev/null +++ b/internal/validate/path_test.go @@ -0,0 +1,285 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) { + for _, tt := range []struct { + name string + input string + wantErr bool + }{ + // ── GIVEN: normal relative paths → THEN: allowed ── + {"normal file", "report.xlsx", false}, + {"subdir file", "output/report.xlsx", false}, + {"current dir explicit", "./file.txt", false}, + {"nested subdir", "a/b/c/file.txt", false}, + {"dot in name", "my.report.v2.xlsx", false}, + {"space in name", "my file.txt", false}, + {"unicode normal", "报告.xlsx", false}, + {"dot-dot resolves to cwd", "subdir/..", false}, + + // ── GIVEN: path traversal via .. → THEN: rejected ── + {"dot-dot escape", "../../.ssh/authorized_keys", true}, + {"dot-dot mid path", "subdir/../../etc/passwd", true}, + {"triple dot-dot", "../../../etc/shadow", true}, + + // ── GIVEN: absolute paths → THEN: rejected ── + {"absolute path unix", "/etc/passwd", true}, + {"absolute path root", "/tmp/evil", true}, + + // ── GIVEN: control characters in path → THEN: rejected ── + {"null byte", "file\x00.txt", true}, + {"carriage return", "file\r.txt", true}, + {"bell char", "file\x07.txt", true}, + + // ── GIVEN: dangerous Unicode in path → THEN: rejected ── + {"bidi RLO", "file\u202Ename.txt", true}, + {"zero width space", "file\u200Bname.txt", true}, + {"BOM char", "file\uFEFFname.txt", true}, + {"line separator", "file\u2028name.txt", true}, + {"bidi LRI", "file\u2066name.txt", true}, + + // ── GIVEN: looks dangerous but is actually safe → THEN: allowed ── + {"literal percent 2e", "%2e%2e/etc/passwd", false}, + {"tilde path", "~/file.txt", false}, + } { + t.Run(tt.name, func(t *testing.T) { + // WHEN: SafeOutputPath validates the path + _, err := SafeOutputPath(tt.input) + + // THEN: error matches expectation + if (err != nil) != tt.wantErr { + t.Errorf("SafeOutputPath(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + }) + } +} + +func TestSafeOutputPath_ReturnsCanonicalAbsolutePath(t *testing.T) { + // GIVEN: a clean temp directory as CWD + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) + + // WHEN: SafeOutputPath validates a relative path + got, err := SafeOutputPath("output/file.txt") + + // THEN: returns the canonical absolute path for subsequent I/O + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := filepath.Join(dir, "output", "file.txt") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSafeOutputPath_RejectsSymlinkEscapingCWD(t *testing.T) { + // GIVEN: a symlink in CWD pointing to /etc (outside CWD) + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) + os.Symlink("/etc", filepath.Join(dir, "link-to-etc")) + + // WHEN: SafeOutputPath validates a path through the symlink + _, err := SafeOutputPath("link-to-etc/passwd") + + // THEN: rejected because the resolved path is outside CWD + if err == nil { + t.Error("expected error for symlink escaping CWD, got nil") + } +} + +func TestSafeOutputPath_AllowsSymlinkWithinCWD(t *testing.T) { + // GIVEN: a symlink in CWD pointing to a subdirectory within CWD + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) + os.MkdirAll(filepath.Join(dir, "real"), 0755) + os.Symlink(filepath.Join(dir, "real"), filepath.Join(dir, "link")) + + // WHEN: SafeOutputPath validates a path through the internal symlink + got, err := SafeOutputPath("link/file.txt") + + // THEN: allowed, resolved to the real path within CWD + if err != nil { + t.Fatalf("symlink within CWD should be allowed: %v", err) + } + want := filepath.Join(dir, "real", "file.txt") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSafeOutputPath_ResolvesAncestorSymlinkWhenParentMissing(t *testing.T) { + // GIVEN: CWD contains a symlink "escape" → /etc, and the target path + // goes through "escape/sub/file.txt" where "sub" does not exist. + // The old code failed to resolve the symlink because the immediate + // parent ("escape/sub") didn't exist, leaving resolved un-anchored. + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) + os.Symlink("/etc", filepath.Join(dir, "escape")) + + // WHEN: SafeOutputPath validates a path through the symlink with missing intermediate dirs + _, err := SafeOutputPath("escape/nonexistent/file.txt") + + // THEN: rejected — the resolved path is under /etc, outside CWD + if err == nil { + t.Error("expected error for symlink escaping CWD via non-existent parent, got nil") + } +} + +func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) { + // GIVEN: a deeply nested non-existent path with no symlinks + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(dir) + + // WHEN: SafeOutputPath validates "a/b/c/d/file.txt" (none of a/b/c/d exist) + got, err := SafeOutputPath("a/b/c/d/file.txt") + + // THEN: allowed, resolved to canonical path under CWD + if err != nil { + t.Fatalf("deep non-existent path within CWD should be allowed: %v", err) + } + want := filepath.Join(dir, "a", "b", "c", "d", "file.txt") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSafeLocalFlagPath(t *testing.T) { + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + orig, _ := os.Getwd() + defer os.Chdir(orig) + os.Chdir(dir) + os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("data"), 0600) + + for _, tt := range []struct { + name string + flag string + value string + want string + wantErr string + }{ + {"empty value passes through", "--image", "", "", ""}, + {"http URL passes through", "--image", "http://example.com/a.jpg", "http://example.com/a.jpg", ""}, + {"https URL passes through", "--image", "https://example.com/a.jpg", "https://example.com/a.jpg", ""}, + {"relative path accepted, returned unchanged", "--file", "photo.jpg", "photo.jpg", ""}, + {"path traversal rejected", "--file", "../escape.txt", "", "--file"}, + {"absolute path rejected", "--image", "/etc/passwd", "", "--image"}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := SafeLocalFlagPath(tt.flag, tt.value) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("SafeLocalFlagPath(%q, %q) error = %v, want contains %q", tt.flag, tt.value, err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("SafeLocalFlagPath(%q, %q) unexpected error: %v", tt.flag, tt.value, err) + } + if got != tt.want { + t.Fatalf("SafeLocalFlagPath(%q, %q) = %q, want %q", tt.flag, tt.value, got, tt.want) + } + }) + } +} + +func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) { + // GIVEN: a real temp file (absolute path under os.TempDir()) + f, err := os.CreateTemp("", "upload-test-*.bin") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + tmpPath := f.Name() + f.Close() + t.Cleanup(func() { os.Remove(tmpPath) }) + + // WHEN: SafeUploadPath validates the absolute temp path + _, err = SafeInputPath(tmpPath) + + // THEN: absolute paths are rejected even in temp dir + if err == nil { + t.Fatal("expected error for absolute temp path, got nil") + } +} + +func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) { + // GIVEN: an absolute path outside the temp directory + // WHEN / THEN: SafeUploadPath rejects it + _, err := SafeInputPath("/etc/passwd") + if err == nil { + t.Error("expected error for absolute non-temp path, got nil") + } +} + +func TestSafeUploadPath_AcceptsRelativePath(t *testing.T) { + // GIVEN: a clean temp CWD with a real file + dir := t.TempDir() + dir, _ = filepath.EvalSymlinks(dir) + orig, _ := os.Getwd() + defer os.Chdir(orig) + os.Chdir(dir) + + os.WriteFile(filepath.Join(dir, "upload.bin"), []byte("data"), 0600) + + // WHEN: SafeUploadPath validates a relative path to an existing file + got, err := SafeInputPath("upload.bin") + + // THEN: accepted and returned as absolute canonical path + if err != nil { + t.Fatalf("SafeUploadPath(relative) error = %v", err) + } + want := filepath.Join(dir, "upload.bin") + if got != want { + t.Errorf("SafeUploadPath(relative) = %q, want %q", got, want) + } +} + +func TestSafeInputPath_ErrorMessageContainsCorrectFlagName(t *testing.T) { + // GIVEN: an absolute path + + // WHEN: SafeInputPath rejects it + _, err := SafeInputPath("/etc/passwd") + + // THEN: error message mentions --file (not --output) + if err == nil { + t.Fatal("expected error for absolute path") + } + if !strings.Contains(err.Error(), "--file") { + t.Errorf("error should mention --file, got: %s", err.Error()) + } + + // WHEN: SafeOutputPath rejects it + _, err = SafeOutputPath("/etc/passwd") + + // THEN: error message mentions --output (not --file) + if err == nil { + t.Fatal("expected error for absolute path") + } + if !strings.Contains(err.Error(), "--output") { + t.Errorf("error should mention --output, got: %s", err.Error()) + } +} diff --git a/internal/validate/resource.go b/internal/validate/resource.go new file mode 100644 index 00000000..63e13210 --- /dev/null +++ b/internal/validate/resource.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +// unsafeResourceChars matches URL-special characters, control characters, +// and percent signs (to prevent %2e%2e encoding bypass). +var unsafeResourceChars = regexp.MustCompile(`[?#%\x00-\x1f\x7f]`) + +// ResourceName validates an API resource identifier (messageId, fileToken, etc.) +// before it is interpolated into a URL path via fmt.Sprintf. It rejects path +// traversal (..), URL metacharacters (?#%), percent-encoded bypasses (%2e%2e), +// control characters, and dangerous Unicode. +// +// Without this check, an input like "../admin" or "?evil=true" in a message ID +// would alter the API endpoint the request is sent to. Works alongside +// EncodePathSegment for defense-in-depth. +func ResourceName(name, flagName string) error { + if name == "" { + return fmt.Errorf("%s must not be empty", flagName) + } + for _, seg := range strings.Split(name, "/") { + if seg == ".." { + return fmt.Errorf("%s must not contain '..' path traversal", flagName) + } + } + if unsafeResourceChars.MatchString(name) { + return fmt.Errorf("%s contains invalid characters", flagName) + } + for _, r := range name { + if isDangerousUnicode(r) { + return fmt.Errorf("%s contains dangerous Unicode characters", flagName) + } + } + return nil +} + +// EncodePathSegment percent-encodes user input for safe use as a single URL path +// segment (e.g. / → %2F, ? → %3F, # → %23), ensuring the value cannot alter the +// URL routing structure when interpolated into an API path. +// +// This provides defense-in-depth alongside ResourceName: ResourceName rejects known +// dangerous patterns at the input layer, while EncodePathSegment acts as a fallback +// at the concatenation layer — if ResourceName rules are relaxed in the future, or +// if an API path bypasses ResourceName validation (e.g. cmd/service/ generic calls), +// encoding still prevents special characters from being interpreted as path separators +// or query parameters. +// +// Convention: all user-provided variables in fmt.Sprintf API paths within shortcuts/ +// MUST be wrapped with this function. +func EncodePathSegment(s string) string { + return url.PathEscape(s) +} diff --git a/internal/validate/resource_test.go b/internal/validate/resource_test.go new file mode 100644 index 00000000..04fe9220 --- /dev/null +++ b/internal/validate/resource_test.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "strings" + "testing" +) + +func TestResourceName_RejectsInjectionPatterns(t *testing.T) { + for _, tt := range []struct { + name string + input string + flag string + wantErr bool + }{ + // ── GIVEN: normal API identifiers → THEN: allowed ── + {"normal id", "om_abc123", "--message", false}, + {"file token", "boxcnXYZ789", "--file-token", false}, + {"with slash", "files/abc", "--resource", false}, + {"with underscore", "om_xxx_yyy", "--message", false}, + {"with hyphen", "file-token-123", "--file-token", false}, + {"single char", "a", "--id", false}, + {"slash only", "/", "--id", false}, + + // ── GIVEN: path traversal attempts → THEN: rejected ── + {"dot-dot traversal", "../admin/secret", "--message", true}, + {"mid path traversal", "files/../admin", "--message", true}, + {"bare dot-dot", "..", "--message", true}, + + // ── GIVEN: URL special characters → THEN: rejected ── + {"question mark", "id?admin=true", "--id", true}, + {"hash fragment", "id#section", "--id", true}, + {"percent encoding", "id%2e%2e", "--id", true}, + + // ── GIVEN: control characters → THEN: rejected ── + {"null byte", "id\x00rest", "--id", true}, + {"newline", "id\nrest", "--id", true}, + {"tab", "id\trest", "--id", true}, + {"escape char", "id\x1brest", "--id", true}, + + // ── GIVEN: dangerous Unicode → THEN: rejected ── + {"bidi RLO", "om_\u202Exxx", "--message", true}, + {"zero width space", "om_\u200Bxxx", "--message", true}, + {"BOM", "om_\uFEFFxxx", "--message", true}, + + // ── GIVEN: empty input → THEN: rejected ── + {"empty string", "", "--message", true}, + } { + t.Run(tt.name, func(t *testing.T) { + // WHEN: ResourceName validates the identifier + err := ResourceName(tt.input, tt.flag) + + // THEN: error matches expectation + if (err != nil) != tt.wantErr { + t.Errorf("ResourceName(%q, %q) error = %v, wantErr %v", + tt.input, tt.flag, err, tt.wantErr) + } + }) + } +} + +func TestResourceName_ErrorMessageContainsFlagName(t *testing.T) { + // GIVEN: an empty resource name with flag "--file-token" + + // WHEN: ResourceName rejects it + err := ResourceName("", "--file-token") + + // THEN: the error message contains the flag name for user-facing diagnostics + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--file-token") { + t.Errorf("error should contain flag name, got: %s", err.Error()) + } +} + +func TestEncodePathSegment_EncodesSpecialCharacters(t *testing.T) { + for _, tt := range []struct { + name string + input string + want string + }{ + // ── GIVEN: safe characters → THEN: unchanged ── + {"normal", "om_abc123", "om_abc123"}, + {"empty", "", ""}, + + // ── GIVEN: URL-special characters → THEN: percent-encoded ── + {"slash", "a/b", "a%2Fb"}, + {"space", "hello world", "hello%20world"}, + {"question mark", "id?foo", "id%3Ffoo"}, + {"hash", "id#bar", "id%23bar"}, + {"dot-dot", "../admin", "..%2Fadmin"}, + {"percent", "50%done", "50%25done"}, + {"unicode", "报告", "%E6%8A%A5%E5%91%8A"}, + } { + t.Run(tt.name, func(t *testing.T) { + // WHEN: EncodePathSegment encodes the input + got := EncodePathSegment(tt.input) + + // THEN: output matches expected encoding + if got != tt.want { + t.Errorf("EncodePathSegment(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/validate/sanitize.go b/internal/validate/sanitize.go new file mode 100644 index 00000000..1e9bd027 --- /dev/null +++ b/internal/validate/sanitize.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "regexp" + "strings" +) + +// ansiEscape matches ANSI CSI sequences (ESC[ ... letter) and OSC sequences (ESC] ... BEL). +// Private CSI sequences (e.g. ESC[?25l) use the extended parameter byte range [0-9;?>=!]. +var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;?>=!]*[a-zA-Z]|\x1b\][^\x07]*\x07`) + +// SanitizeForTerminal strips ANSI escape sequences, C0 control characters +// (except \n and \t), and dangerous Unicode from text, preserving the actual +// readable content. It should be applied to table format output and stderr +// messages, but NOT to json/ndjson output where programmatic consumers need +// the raw data. +// +// API responses may contain injected ANSI sequences that clear the screen, +// fake a colored "OK" status, or change the terminal title. In AI Agent +// scenarios, such injections can also pollute the LLM's context window +// with misleading output. +func SanitizeForTerminal(text string) string { + if strings.ContainsRune(text, '\x1b') { + text = ansiEscape.ReplaceAllString(text, "") + } + var b strings.Builder + b.Grow(len(text)) + for _, r := range text { + switch { + case r == '\n' || r == '\t': + b.WriteRune(r) + case r < 0x20 || r == 0x7f: + continue + case isDangerousUnicode(r): + continue + default: + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/validate/sanitize_test.go b/internal/validate/sanitize_test.go new file mode 100644 index 00000000..be353bdf --- /dev/null +++ b/internal/validate/sanitize_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "testing" +) + +func TestSanitizeForTerminal_StripsEscapesAndDangerousChars(t *testing.T) { + for _, tt := range []struct { + name string + input string + want string + }{ + // ── GIVEN: normal text → THEN: unchanged ── + {"plain text", "hello world", "hello world"}, + {"unicode text", "你好世界", "你好世界"}, + {"empty", "", ""}, + + // ── GIVEN: tab and newline → THEN: preserved ── + {"preserve tab", "col1\tcol2", "col1\tcol2"}, + {"preserve newline", "line1\nline2", "line1\nline2"}, + + // ── GIVEN: ANSI CSI sequences → THEN: stripped, text preserved ── + {"clear screen", "before\x1b[2Jafter", "beforeafter"}, + {"red color", "before\x1b[31mred\x1b[0mafter", "beforeredafter"}, + {"bold", "before\x1b[1mbold\x1b[0mafter", "beforeboldafter"}, + {"cursor move", "before\x1b[10;20Hafter", "beforeafter"}, + {"multiple sequences", "\x1b[31m\x1b[1mhello\x1b[0m", "hello"}, + + // ── GIVEN: ANSI OSC sequences → THEN: stripped ── + {"OSC title change", "before\x1b]0;evil title\x07after", "beforeafter"}, + {"OSC with text", "text\x1b]2;new title\x07more", "textmore"}, + + // ── GIVEN: C0 control characters → THEN: stripped ── + {"null byte", "hello\x00world", "helloworld"}, + {"bell", "hello\x07world", "helloworld"}, + {"backspace", "hello\x08world", "helloworld"}, + {"escape alone", "hello\x1bworld", "helloworld"}, + {"carriage return", "hello\rworld", "helloworld"}, + {"DEL", "hello\x7fworld", "helloworld"}, + + // ── GIVEN: dangerous Unicode → THEN: stripped ── + {"zero width space", "hello\u200Bworld", "helloworld"}, + {"BOM", "hello\uFEFFworld", "helloworld"}, + {"bidi RLO", "hello\u202Eworld", "helloworld"}, + {"bidi LRI", "hello\u2066world", "helloworld"}, + {"line separator", "hello\u2028world", "helloworld"}, + + // ── GIVEN: mixed attack payload → THEN: all dangerous content stripped ── + {"ansi + null + bidi", "\x1b[31m\x00\u202Ehello\x1b[0m", "hello"}, + {"realistic injection", "Status: \x1b[32mOK\x1b[0m (fake)", "Status: OK (fake)"}, + } { + t.Run(tt.name, func(t *testing.T) { + // WHEN: SanitizeForTerminal processes the input + got := SanitizeForTerminal(tt.input) + + // THEN: output matches expected sanitized result + if got != tt.want { + t.Errorf("SanitizeForTerminal(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestIsDangerousUnicode_IdentifiesAllDangerousRanges(t *testing.T) { + // ── GIVEN: known dangerous Unicode code points → THEN: returns true ── + dangerous := []rune{ + 0x200B, 0x200C, 0x200D, // zero-width + 0xFEFF, // BOM + 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, // bidi + 0x2028, 0x2029, // separators + 0x2066, 0x2067, 0x2068, 0x2069, // isolates + } + for _, r := range dangerous { + if !isDangerousUnicode(r) { + t.Errorf("isDangerousUnicode(%U) = false, want true", r) + } + } + + // ── GIVEN: safe Unicode code points → THEN: returns false ── + safe := []rune{'A', '中', '!', ' ', '\t', '\n', 0x200A, 0x2070} + for _, r := range safe { + if isDangerousUnicode(r) { + t.Errorf("isDangerousUnicode(%U) = true, want false", r) + } + } +} diff --git a/internal/validate/url.go b/internal/validate/url.go new file mode 100644 index 00000000..6d6ab0c4 --- /dev/null +++ b/internal/validate/url.go @@ -0,0 +1,212 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package validate + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "strings" +) + +const ( + defaultDownloadMaxRedirects = 5 +) + +// DownloadHTTPClientOptions controls redirect and scheme behavior for +// untrusted-source downloads. +type DownloadHTTPClientOptions struct { + // AllowHTTP controls whether plain HTTP URLs are permitted. + // If false, any HTTP URL (initial or redirect target) is rejected. + AllowHTTP bool + // MaxRedirects limits follow-up redirects. Zero or negative uses default. + MaxRedirects int +} + +func isRestrictedDownloadIP(ip net.IP) bool { + if ip == nil { + return true + } + if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + if v4 := ip.To4(); v4 != nil { + if v4[0] == 10 || v4[0] == 127 { + return true + } + if v4[0] == 169 && v4[1] == 254 { + return true + } + if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 { + return true + } + if v4[0] == 192 && v4[1] == 168 { + return true + } + if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 { // RFC6598 CGNAT + return true + } + if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) { // RFC2544 benchmarking + return true + } + return false + } + if ip.IsPrivate() { + return true + } + ip16 := ip.To16() + if ip16 == nil { + return true + } + if ip16[0]&0xfe == 0xfc { // fc00::/7 unique local address + return true + } + return false +} + +// ValidateDownloadSourceURL validates a download URL and blocks local/internal targets. +func ValidateDownloadSourceURL(ctx context.Context, rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil || u == nil { + return fmt.Errorf("invalid URL") + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("only http/https URLs are supported") + } + host := strings.TrimSpace(strings.ToLower(u.Hostname())) + if host == "" { + return fmt.Errorf("URL host is required") + } + if host == "localhost" || strings.HasSuffix(host, ".localhost") { + return fmt.Errorf("local/internal host is not allowed") + } + if ip := net.ParseIP(host); ip != nil { + if isRestrictedDownloadIP(ip) { + return fmt.Errorf("local/internal host is not allowed") + } + return nil + } + ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host) + if err != nil { + return fmt.Errorf("failed to resolve host") + } + if len(ips) == 0 { + return fmt.Errorf("failed to resolve host") + } + for _, ip := range ips { + if isRestrictedDownloadIP(ip) { + return fmt.Errorf("local/internal host is not allowed") + } + } + return nil +} + +// NewDownloadHTTPClient clones base client and enforces download-safe redirect +// and connection rules for untrusted URLs. +func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *http.Client { + if base == nil { + base = &http.Client{} + } + if opts.MaxRedirects <= 0 { + opts.MaxRedirects = defaultDownloadMaxRedirects + } + + cloned := *base + cloned.Transport = cloneDownloadTransport(base.Transport) + cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= opts.MaxRedirects { + return fmt.Errorf("too many redirects") + } + if len(via) > 0 { + prev := via[len(via)-1] + if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") { + return fmt.Errorf("redirect from https to http is not allowed") + } + } + if !opts.AllowHTTP && !strings.EqualFold(req.URL.Scheme, "https") { + return fmt.Errorf("only https URLs are supported") + } + if err := ValidateDownloadSourceURL(req.Context(), req.URL.String()); err != nil { + return fmt.Errorf("blocked redirect target: %w", err) + } + return nil + } + + return &cloned +} + +func cloneDownloadTransport(base http.RoundTripper) *http.Transport { + var cloned *http.Transport + if src, ok := base.(*http.Transport); ok && src != nil { + cloned = src.Clone() + } else { + if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil { + cloned = def.Clone() + } else { + cloned = &http.Transport{} + } + } + + origDial := cloned.DialContext + cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := dialConn(ctx, origDial, network, addr) + if err != nil { + return nil, err + } + if err := validateConnRemoteIP(conn); err != nil { + conn.Close() + return nil, err + } + return conn, nil + } + + if cloned.DialTLSContext != nil { + origDialTLS := cloned.DialTLSContext + cloned.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := dialConn(ctx, origDialTLS, network, addr) + if err != nil { + return nil, err + } + if err := validateConnRemoteIP(conn); err != nil { + conn.Close() + return nil, err + } + return conn, nil + } + } + + return cloned +} + +func dialConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) { + if dialFn != nil { + return dialFn(ctx, network, addr) + } + var d net.Dialer + return d.DialContext(ctx, network, addr) +} + +func validateConnRemoteIP(conn net.Conn) error { + if conn == nil { + return fmt.Errorf("nil connection") + } + raddr := conn.RemoteAddr() + if raddr == nil { + return fmt.Errorf("missing remote address") + } + host, _, err := net.SplitHostPort(raddr.String()) + if err != nil { + host = raddr.String() + } + ip := net.ParseIP(strings.Trim(host, "[]")) + if ip == nil { + return fmt.Errorf("invalid remote IP") + } + if isRestrictedDownloadIP(ip) { + return fmt.Errorf("local/internal host is not allowed") + } + return nil +} diff --git a/main.go b/main.go new file mode 100644 index 00000000..568ddfd9 --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT +// +// lark-cli — Feishu/Lark CLI tool (Go implementation). +package main + +import ( + "os" + + "github.com/larksuite/cli/cmd" +) + +func main() { + os.Exit(cmd.Execute()) +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..13e68149 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "@larksuite/cli", + "version": "1.0.0", + "description": "The official CLI for Lark/Feishu open platform", + "bin": { + "lark-cli": "scripts/run.js" + }, + "scripts": { + "postinstall": "node scripts/install.js" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ], + "engines": { + "node": ">=16" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/larksuite/cli.git" + }, + "license": "MIT", + "files": [ + "scripts/install.js", + "scripts/run.js", + "CHANGELOG.md" + ] +} diff --git a/scripts/fetch_meta.py b/scripts/fetch_meta.py new file mode 100644 index 00000000..4c0145b4 --- /dev/null +++ b/scripts/fetch_meta.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Fetch meta_data.json from remote API for build-time embedding. + +Usage: + python3 scripts/fetch_meta.py # fetch from feishu (default) + python3 scripts/fetch_meta.py --brand lark # fetch from larksuite +""" + +import argparse +import json +import os +import subprocess +import sys +import urllib.request +import urllib.error + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.join(SCRIPT_DIR, "..") +OUT_PATH = os.path.join(ROOT, "internal", "registry", "meta_data.json") + +API_HOSTS = { + "feishu": "https://open.feishu.cn/api/tools/open/api_definition", + "lark": "https://open.larksuite.com/api/tools/open/api_definition", +} + +TIMEOUT = 10 # seconds + + +def get_version(): + """Get version from git tags, matching Makefile logic.""" + try: + return subprocess.check_output( + ["git", "describe", "--tags", "--always", "--dirty"], + stderr=subprocess.DEVNULL, + text=True, + cwd=ROOT, + ).strip() + except Exception: + return "dev" + + +def fetch_remote(brand): + """Fetch MergedRegistry from remote API.""" + base = API_HOSTS.get(brand, API_HOSTS["feishu"]) + version = get_version() + url = f"{base}?protocol=meta&client_version={urllib.request.quote(version)}" + + print(f"fetch-meta: GET {url}", file=sys.stderr) + req = urllib.request.Request(url) + resp = urllib.request.urlopen(req, timeout=TIMEOUT) + body = resp.read() + + envelope = json.loads(body) + if envelope.get("msg") != "succeeded": + raise RuntimeError(f"unexpected response msg: {envelope.get('msg')!r}") + + data = envelope.get("data", {}) + if not data.get("services"): + raise RuntimeError("remote returned empty services") + + return data + + +def main(): + parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding") + parser.add_argument("--brand", default="feishu", choices=["feishu", "lark"], + help="API brand (default: feishu)") + args = parser.parse_args() + + data = fetch_remote(args.brand) + count = len(data.get("services", [])) + print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr) + + with open(OUT_PATH, "w") as fp: + json.dump(data, fp, ensure_ascii=False, indent=2) + fp.write("\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/install.js b/scripts/install.js new file mode 100644 index 00000000..db0eb4ff --- /dev/null +++ b/scripts/install.js @@ -0,0 +1,100 @@ +const fs = require("fs"); +const path = require("path"); +const https = require("https"); +const { execSync } = require("child_process"); +const os = require("os"); + +const VERSION = require("../package.json").version; +const REPO = "larksuite/cli"; +const NAME = "lark-cli"; + +const PLATFORM_MAP = { + darwin: "darwin", + linux: "linux", + win32: "windows", +}; + +const ARCH_MAP = { + x64: "amd64", + arm64: "arm64", +}; + +const platform = PLATFORM_MAP[process.platform]; +const arch = ARCH_MAP[process.arch]; + +if (!platform || !arch) { + console.error( + `Unsupported platform: ${process.platform}-${process.arch}` + ); + process.exit(1); +} + +const isWindows = process.platform === "win32"; +const ext = isWindows ? ".zip" : ".tar.gz"; +const archiveName = `${NAME}-${VERSION}-${platform}-${arch}${ext}`; +const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${archiveName}`; +const binDir = path.join(__dirname, "..", "bin"); +const dest = path.join(binDir, NAME + (isWindows ? ".exe" : "")); + +fs.mkdirSync(binDir, { recursive: true }); + +function download(url, destPath) { + return new Promise((resolve, reject) => { + const client = url.startsWith("https") ? https : require("http"); + client + .get(url, (res) => { + if (res.statusCode === 302 || res.statusCode === 301) { + return download(res.headers.location, destPath).then( + resolve, + reject + ); + } + if (res.statusCode !== 200) { + return reject( + new Error(`Download failed with status ${res.statusCode}: ${url}`) + ); + } + const file = fs.createWriteStream(destPath); + res.pipe(file); + file.on("finish", () => { + file.close(); + resolve(); + }); + }) + .on("error", reject); + }); +} + +async function install() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lark-cli-")); + const archivePath = path.join(tmpDir, archiveName); + + try { + await download(url, archivePath); + + if (isWindows) { + execSync( + `powershell -Command "Expand-Archive -Path '${archivePath}' -DestinationPath '${tmpDir}'"`, + { stdio: "ignore" } + ); + } else { + execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { + stdio: "ignore", + }); + } + + const binaryName = NAME + (isWindows ? ".exe" : ""); + const extractedBinary = path.join(tmpDir, binaryName); + + fs.copyFileSync(extractedBinary, dest); + fs.chmodSync(dest, 0o755); + console.log(`${NAME} v${VERSION} installed successfully`); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +install().catch((err) => { + console.error(`Failed to install ${NAME}:`, err.message); + process.exit(1); +}); diff --git a/scripts/run.js b/scripts/run.js new file mode 100644 index 00000000..1b2477e8 --- /dev/null +++ b/scripts/run.js @@ -0,0 +1,12 @@ +#!/usr/bin/env node +const { execFileSync } = require("child_process"); +const path = require("path"); + +const ext = process.platform === "win32" ? ".exe" : ""; +const bin = path.join(__dirname, "..", "bin", "lark-cli" + ext); + +try { + execFileSync(bin, process.argv.slice(2), { stdio: "inherit" }); +} catch (e) { + process.exit(e.status || 1); +} diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh new file mode 100755 index 00000000..c3b486f9 --- /dev/null +++ b/scripts/tag-release.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Read version from package.json +VERSION=$(node -p "require('${REPO_ROOT}/package.json').version") + +if [ -z "$VERSION" ]; then + echo "Error: could not read version from package.json" >&2 + exit 1 +fi + +TAG="v${VERSION}" + +echo "Version: ${VERSION}" +echo "Tag: ${TAG}" + +# Check if tag already exists locally +if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag ${TAG} already exists locally, skipping." + exit 0 +fi + +# Check if tag already exists on remote +if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then + echo "Tag ${TAG} already exists on remote, skipping." + exit 0 +fi + +# Ensure package.json changes are committed before tagging +if git diff --name-only | grep -q 'package.json' || git diff --cached --name-only | grep -q 'package.json'; then + echo "Error: package.json has uncommitted changes. Please commit before tagging." >&2 + exit 1 +fi + +# Ensure current branch is pushed to remote before tagging +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +LOCAL_SHA=$(git rev-parse HEAD) +REMOTE_SHA=$(git rev-parse "origin/${CURRENT_BRANCH}" 2>/dev/null || echo "") +if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then + echo "Error: local branch '${CURRENT_BRANCH}' is not in sync with remote. Please push your commits first." >&2 + exit 1 +fi + +# Create and push tag +git tag "$TAG" +git push origin "$TAG" + +echo "Successfully created and pushed tag ${TAG}" diff --git a/shortcuts/base/base_advperm_disable.go b/shortcuts/base/base_advperm_disable.go new file mode 100644 index 00000000..3266d9bc --- /dev/null +++ b/shortcuts/base/base_advperm_disable.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseAdvpermDisable = common.Shortcut{ + Service: "base", + Command: "+advperm-disable", + Description: "Disable advanced permissions for a Base", + Risk: "high-risk-write", + Scopes: []string{"base:app:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PUT("/open-apis/base/v3/bases/:base_token/advperm/enable?enable=false"). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + + queryParams := make(larkcore.QueryParams) + queryParams.Set("enable", "false") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPut, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/advperm/enable", validate.EncodePathSegment(baseToken)), + QueryParams: queryParams, + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "disable advanced permissions failed") + }, +} diff --git a/shortcuts/base/base_advperm_enable.go b/shortcuts/base/base_advperm_enable.go new file mode 100644 index 00000000..2f7437ca --- /dev/null +++ b/shortcuts/base/base_advperm_enable.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseAdvpermEnable = common.Shortcut{ + Service: "base", + Command: "+advperm-enable", + Description: "Enable advanced permissions for a Base", + Risk: "write", + Scopes: []string{"base:app:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PUT("/open-apis/base/v3/bases/:base_token/advperm/enable?enable=true"). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + + queryParams := make(larkcore.QueryParams) + queryParams.Set("enable", "true") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPut, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/advperm/enable", validate.EncodePathSegment(baseToken)), + QueryParams: queryParams, + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "enable advanced permissions failed") + }, +} diff --git a/shortcuts/base/base_advperm_test.go b/shortcuts/base/base_advperm_test.go new file mode 100644 index 00000000..db0be579 --- /dev/null +++ b/shortcuts/base/base_advperm_test.go @@ -0,0 +1,238 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// --------------------------------------------------------------------------- +// Validate tests +// --------------------------------------------------------------------------- + +func TestBaseAdvpermEnableValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": ""}, nil, nil) + if err := BaseAdvpermEnable.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("whitespace base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": " "}, nil, nil) + if err := BaseAdvpermEnable.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + if err := BaseAdvpermEnable.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseAdvpermDisableValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": ""}, nil, nil) + if err := BaseAdvpermDisable.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("whitespace base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": " "}, nil, nil) + if err := BaseAdvpermDisable.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + if err := BaseAdvpermDisable.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +// --------------------------------------------------------------------------- +// DryRun tests +// --------------------------------------------------------------------------- + +func TestBaseAdvpermEnableDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + dr := BaseAdvpermEnable.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +func TestBaseAdvpermDisableDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + dr := BaseAdvpermDisable.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +// --------------------------------------------------------------------------- +// Metadata tests +// --------------------------------------------------------------------------- + +func TestBaseAdvpermMetadata(t *testing.T) { + t.Run("enable", func(t *testing.T) { + s := BaseAdvpermEnable + if s.Command != "+advperm-enable" { + t.Fatalf("command=%q", s.Command) + } + if s.Risk != "write" { + t.Fatalf("risk=%q", s.Risk) + } + if s.Service != "base" { + t.Fatalf("service=%q", s.Service) + } + if len(s.Scopes) != 1 || s.Scopes[0] != "base:app:update" { + t.Fatalf("scopes=%v", s.Scopes) + } + }) + + t.Run("disable", func(t *testing.T) { + s := BaseAdvpermDisable + if s.Command != "+advperm-disable" { + t.Fatalf("command=%q", s.Command) + } + if s.Risk != "high-risk-write" { + t.Fatalf("risk=%q", s.Risk) + } + if s.Service != "base" { + t.Fatalf("service=%q", s.Service) + } + if len(s.Scopes) != 1 || s.Scopes[0] != "base:app:update" { + t.Fatalf("scopes=%v", s.Scopes) + } + }) +} + +// --------------------------------------------------------------------------- +// Execute tests (happy path) +// --------------------------------------------------------------------------- + +func TestBaseAdvpermEnableExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/advperm/enable", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": nil, + }, + }) + args := []string{"+advperm-enable", "--base-token", "app_x"} + if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "success") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseAdvpermDisableExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/advperm/enable", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": nil, + }, + }) + args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"} + if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "success") { + t.Fatalf("stdout=%s", got) + } +} + +// --------------------------------------------------------------------------- +// Execute error paths +// --------------------------------------------------------------------------- + +func TestBaseAdvpermEnableExecuteTransportError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/advperm/enable", + Status: 500, + Body: "internal server error", + }) + args := []string{"+advperm-enable", "--base-token", "app_x"} + if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil { + t.Fatal("expected error") + } +} + +func TestBaseAdvpermEnableExecuteAPIError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/advperm/enable", + Body: map[string]interface{}{ + "code": 190001, + "msg": "bad request", + }, + }) + args := []string{"+advperm-enable", "--base-token", "app_x"} + if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/advperm/enable", + Status: 500, + Body: "internal server error", + }) + args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"} + if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil { + t.Fatal("expected error") + } +} + +func TestBaseAdvpermDisableExecuteAPIError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/advperm/enable", + Body: map[string]interface{}{ + "code": 190002, + "msg": "permission denied", + }, + }) + args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"} + if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") { + t.Fatalf("err=%v", err) + } +} diff --git a/shortcuts/base/base_command_common.go b/shortcuts/base/base_command_common.go new file mode 100644 index 00000000..58c3be8e --- /dev/null +++ b/shortcuts/base/base_command_common.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import "github.com/larksuite/cli/shortcuts/common" + +func authTypes() []string { + return []string{"user", "bot"} +} + +func baseTokenFlag(required bool) common.Flag { + return common.Flag{Name: "base-token", Desc: "base token", Required: required} +} + +func tableRefFlag(required bool) common.Flag { + return common.Flag{Name: "table-id", Desc: "table ID or name", Required: required} +} + +func fieldRefFlag(required bool) common.Flag { + return common.Flag{Name: "field-id", Desc: "field ID or name", Required: required} +} + +func viewRefFlag(required bool) common.Flag { + return common.Flag{Name: "view-id", Desc: "view ID or name", Required: required} +} + +func recordRefFlag(required bool) common.Flag { + return common.Flag{Name: "record-id", Desc: "record ID", Required: required} +} diff --git a/shortcuts/base/base_copy.go b/shortcuts/base/base_copy.go new file mode 100644 index 00000000..ff33f0a1 --- /dev/null +++ b/shortcuts/base/base_copy.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseBaseCopy = common.Shortcut{ + Service: "base", + Command: "+base-copy", + Description: "Copy a base resource", + Risk: "write", + Scopes: []string{"base:app:copy"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "name", Desc: "new base name"}, + {Name: "folder-token", Desc: "folder token for destination"}, + {Name: "without-content", Type: "bool", Desc: "copy structure only"}, + {Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"}, + }, + DryRun: dryRunBaseCopy, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseCopy(runtime) + }, +} diff --git a/shortcuts/base/base_create.go b/shortcuts/base/base_create.go new file mode 100644 index 00000000..b5f69a1e --- /dev/null +++ b/shortcuts/base/base_create.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseBaseCreate = common.Shortcut{ + Service: "base", + Command: "+base-create", + Description: "Create a new base resource", + Risk: "write", + Scopes: []string{"base:app:create"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + {Name: "name", Desc: "base name", Required: true}, + {Name: "folder-token", Desc: "folder token for destination"}, + {Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"}, + }, + DryRun: dryRunBaseCreate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseCreate(runtime) + }, +} diff --git a/shortcuts/base/base_dashboard_execute_test.go b/shortcuts/base/base_dashboard_execute_test.go new file mode 100644 index 00000000..5fbf43af --- /dev/null +++ b/shortcuts/base/base_dashboard_execute_test.go @@ -0,0 +1,625 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// ── Dashboard CRUD ────────────────────────────────────────────────── + +func TestBaseDashboardExecuteList(t *testing.T) { + t.Run("single page", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "total": 2, + "items": []interface{}{ + map[string]interface{}{"dashboard_id": "dsh_001", "name": "销售报表"}, + map[string]interface{}{"dashboard_id": "dsh_002", "name": "运营看板"}, + }, + }, + }, + }) + if err := runShortcut(t, BaseDashboardList, []string{"+dashboard-list", "--base-token", "app_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"dsh_001"`) || !strings.Contains(got, `"dsh_002"`) { + t.Fatalf("stdout=%s", got) + } + }) + +} + +func TestBaseDashboardExecuteGet(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dashboard_id": "dsh_001", + "name": "销售报表", + "theme": map[string]interface{}{"theme_style": "default"}, + "blocks": []interface{}{ + map[string]interface{}{"block_id": "blk_a", "block_name": "柱状图", "block_type": "column"}, + }, + }, + }, + }) + if err := runShortcut(t, BaseDashboardGet, []string{"+dashboard-get", "--base-token", "app_x", "--dashboard-id", "dsh_001"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"dsh_001"`) || !strings.Contains(got, `"销售报表"`) || !strings.Contains(got, `"dashboard"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardExecuteCreate(t *testing.T) { + t.Run("name only", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dashboard_id": "dsh_new", + "name": "新报表", + }, + }, + }) + if err := runShortcut(t, BaseDashboardCreate, []string{"+dashboard-create", "--base-token", "app_x", "--name", "新报表"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"dsh_new"`) || !strings.Contains(got, `"created": true`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("with theme", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dashboard_id": "dsh_themed", + "name": "主题报表", + "theme": map[string]interface{}{"theme_style": "SimpleBlue"}, + }, + }, + }) + if err := runShortcut(t, BaseDashboardCreate, []string{"+dashboard-create", "--base-token", "app_x", "--name", "主题报表", "--theme-style", "SimpleBlue"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"dsh_themed"`) || !strings.Contains(got, `"SimpleBlue"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseDashboardExecuteUpdate(t *testing.T) { + t.Run("update name", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dashboard_id": "dsh_001", + "name": "更新后的名称", + }, + }, + }) + if err := runShortcut(t, BaseDashboardUpdate, []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--name", "更新后的名称"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"更新后的名称"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("update theme", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "dashboard_id": "dsh_001", + "name": "报表", + "theme": map[string]interface{}{"theme_style": "deepDark"}, + }, + }, + }) + if err := runShortcut(t, BaseDashboardUpdate, []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--theme-style", "deepDark"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"deepDark"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseDashboardExecuteDelete(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseDashboardDelete, []string{"+dashboard-delete", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"dashboard_id": "dsh_001"`) { + t.Fatalf("stdout=%s", got) + } +} + +// ── Dashboard Block CRUD ──────────────────────────────────────────── + +func TestBaseDashboardBlockExecuteList(t *testing.T) { + t.Run("single page", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "total": 2, + "items": []interface{}{ + map[string]interface{}{"block_id": "blk_a", "name": "柱状图", "type": "column"}, + map[string]interface{}{"block_id": "blk_b", "name": "指标卡", "type": "statistics"}, + }, + }, + }, + }) + if err := runShortcut(t, BaseDashboardBlockList, []string{"+dashboard-block-list", "--base-token", "app_x", "--dashboard-id", "dsh_001"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"blk_a"`) || !strings.Contains(got, `"blk_b"`) { + t.Fatalf("stdout=%s", got) + } + }) + +} + +func TestBaseDashboardBlockExecuteGet(t *testing.T) { + t.Run("basic", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_a", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_a", + "name": "订单趋势", + "type": "column", + "layout": map[string]interface{}{"x": 0, "y": 0, "w": 12, "h": 6}, + "data_config": map[string]interface{}{ + "table_name": "订单表", + "count_all": true, + }, + }, + }, + }) + if err := runShortcut(t, BaseDashboardBlockGet, []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"blk_a"`) || !strings.Contains(got, `"block"`) || !strings.Contains(got, `"订单趋势"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("with user-id-type", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "user_id_type=union_id", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_a", + "name": "人员图表", + "type": "pie", + }, + }, + }) + if err := runShortcut(t, BaseDashboardBlockGet, []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", "--user-id-type", "union_id"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"blk_a"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseDashboardBlockExecuteCreate(t *testing.T) { + t.Run("with data-config", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_new", + "name": "订单趋势", + "type": "column", + "layout": map[string]interface{}{"x": 0, "y": 0, "w": 12, "h": 6}, + "data_config": map[string]interface{}{ + "table_name": "订单表", + "count_all": true, + }, + }, + }, + }) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + "--name", "订单趋势", "--type", "column", + "--data-config", `{"table_name":"订单表","count_all":true}`} + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"blk_new"`) || !strings.Contains(got, `"created": true`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("statistics with series", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_stat", + "name": "销售总额", + "type": "statistics", + }, + }, + }) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + "--name", "销售总额", "--type", "statistics", + "--data-config", `{"table_name":"数据表","series":[{"field_name":"数字","rollup":"SUM"}]}`} + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"blk_stat"`) || !strings.Contains(got, `"created": true`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("without data-config", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_empty", + "name": "空图表", + "type": "line", + }, + }, + }) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + "--name", "空图表", "--type", "line"} + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"blk_empty"`) || !strings.Contains(got, `"created": true`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("invalid data-config json", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_001", + "--name", "Test", "--type", "column", "--data-config", "not-json"} + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err == nil { + t.Fatalf("expected error for invalid data-config JSON") + } + }) +} + +func TestBaseDashboardBlockExecuteUpdate(t *testing.T) { + t.Run("update name and data-config", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_a", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_a", + "name": "订单趋势v2", + "type": "column", + "data_config": map[string]interface{}{ + "table_name": "订单表2", + "count_all": true, + }, + }, + }, + }) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", + "--name", "订单趋势v2", + "--data-config", `{"table_name":"订单表2","count_all":true}`} + if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"订单趋势v2"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("update name only", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_a", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "block_id": "blk_a", + "name": "仅改名", + "type": "column", + }, + }, + }) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", + "--name", "仅改名"} + if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"仅改名"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("invalid data-config json", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", + "--data-config", "bad-json"} + if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err == nil { + t.Fatalf("expected error for invalid data-config JSON") + } + }) +} + +func TestBaseDashboardBlockExecuteDelete(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_001/blocks/blk_a", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseDashboardBlockDelete, []string{"+dashboard-block-delete", "--base-token", "app_x", "--dashboard-id", "dsh_001", "--block-id", "blk_a", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"block_id": "blk_a"`) { + t.Fatalf("stdout=%s", got) + } +} + +// ── Dry Run: Dashboard & Blocks ────────────────────────────────────── + +func TestBaseDashboardDryRun_List(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + if err := runShortcut(t, BaseDashboardList, []string{"+dashboard-list", "--base-token", "app_x", "--page-size", "50", "--dry-run", "--format", "pretty"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards") || !strings.Contains(got, "page_size=50") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardDryRun_Get(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + if err := runShortcut(t, BaseDashboardGet, []string{"+dashboard-get", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--dry-run", "--format", "pretty"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/dsh_1") || !strings.Contains(got, "dsh_1") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardDryRun_Create(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-create", "--base-token", "app_x", "--name", "新报表", "--theme-style", "default", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "POST /open-apis/base/v3/bases/app_x/dashboards") || !strings.Contains(got, "\"name\":\"新报表\"") || !strings.Contains(got, "theme_style") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardDryRun_Update(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--name", "更新名", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "PATCH /open-apis/base/v3/bases/app_x/dashboards/dsh_1") || !strings.Contains(got, "\"name\":\"更新名\"") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardDryRun_Delete(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-delete", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardDelete, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "DELETE /open-apis/base/v3/bases/app_x/dashboards/dsh_1") || !strings.Contains(got, "dsh_1") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardBlockDryRun_List(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-list", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--page-size", "10", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardBlockList, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks") || !strings.Contains(got, "page_size=10") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardBlockDryRun_Get(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-get", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--user-id-type", "union_id", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardBlockGet, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "GET /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a") || !strings.Contains(got, "union_id") || !strings.Contains(got, "blk_a") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardBlockDryRun_Create(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--name", "订单趋势", "--type", "column", "--data-config", `{"table_name":"订单表","count_all":true}`, "--user-id-type", "open_id", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "POST /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks") || !strings.Contains(got, "\"name\":\"订单趋势\"") || !strings.Contains(got, "table_name") || !strings.Contains(got, "open_id") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardBlockDryRun_Update(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-update", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--name", "订单趋势v2", "--data-config", `{"table_name":"订单表2","count_all":true}`, "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardBlockUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "PATCH /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a") || !strings.Contains(got, "订单趋势v2") || !strings.Contains(got, "订单表2") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardBlockDryRun_Delete(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+dashboard-block-delete", "--base-token", "app_x", "--dashboard-id", "dsh_1", "--block-id", "blk_a", "--dry-run", "--format", "pretty"} + if err := runShortcut(t, BaseDashboardBlockDelete, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "DELETE /open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks/blk_a") || !strings.Contains(got, "blk_a") { + t.Fatalf("stdout=%s", got) + } +} + +// ── Validator: data_config ─────────────────────────────────────────── + +func TestBaseDashboardBlockCreate_ValidateFails(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + // 缺 table_name 且 series 与 count_all 同时存在 + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + "--name", "Bad", "--type", "column", + "--data-config", `{"series":[{"field_name":"金额","rollup":"sum"}],"count_all":true}`, + } + err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout) + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "data_config 校验失败") || !strings.Contains(err.Error(), "table_name") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBaseDashboardBlockCreate_NoValidateFlagAllocs(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "POST", URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/blocks", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"block_id": "blk_ok", "name": "OK", "type": "column"}}, + }) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + "--name", "OK", "--type", "column", "--no-validate", + "--data-config", `{"series":[{"field_name":"金额","rollup":"sum"}],"count_all":true}`, + } + if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "\"blk_ok\"") || !strings.Contains(got, "\"created\": true") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + // 合法 JSON,但 rollup=COUNTA(不支持) + args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1", + "--name", "Bad", "--type", "column", + "--data-config", `{"table_name":"T","series":[{"field_name":"金额","rollup":"COUNTA"}]}`, + } + err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout) + if err == nil { + t.Fatalf("expected validation error for invalid rollup") + } + if got := err.Error(); !strings.Contains(got, "rollup") || !strings.Contains(got, "data_config 校验失败") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/base/base_data_query.go b/shortcuts/base/base_data_query.go new file mode 100644 index 00000000..d316e4f2 --- /dev/null +++ b/shortcuts/base/base_data_query.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "bytes" + "context" + "encoding/json" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDataQuery = common.Shortcut{ + Service: "base", + Command: "+data-query", + Description: "Query and analyze Bitable data with JSON DSL (aggregation, filter, sort)", + Risk: "read", + Scopes: []string{"base:table:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "dsl", Desc: "query JSON DSL (LiteQuery Protocol)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + var dsl map[string]interface{} + dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl")))) + dec.UseNumber() + if err := dec.Decode(&dsl); err != nil { + return common.FlagErrorf("--dsl invalid JSON: %v", err) + } + _, hasDim := dsl["dimensions"] + _, hasMeas := dsl["measures"] + if !hasDim && !hasMeas { + return common.FlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + var dsl map[string]interface{} + dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl")))) + dec.UseNumber() + dec.Decode(&dsl) + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/data/query"). + Body(dsl). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + + var dsl map[string]interface{} + dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl")))) + dec.UseNumber() + dec.Decode(&dsl) + + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "data/query"), nil, dsl) + if err != nil { + return err + } + + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go new file mode 100644 index 00000000..3898826b --- /dev/null +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -0,0 +1,220 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + "testing" +) + +func assertDryRunContains(t *testing.T, dr interface{ Format() string }, wants ...string) { + t.Helper() + out := dr.Format() + for _, want := range wants { + if !strings.Contains(out, want) { + t.Fatalf("dry-run output missing %q\noutput:\n%s", want, out) + } + } +} + +func TestDryRunTableOps(t *testing.T) { + ctx := context.Background() + + listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, map[string]int{"offset": -1, "limit": 999}) + assertDryRunContains(t, dryRunTableList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables", "offset=0", "limit=100") + + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_1", "name": "Orders"}, nil, nil) + assertDryRunContains(t, dryRunTableGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1") + assertDryRunContains(t, dryRunTableCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables") + assertDryRunContains(t, dryRunTableUpdate(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1") + assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1") +} + +func TestDryRunFieldOps(t *testing.T) { + ctx := context.Background() + + listRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1"}, + nil, + map[string]int{"offset": -2, "limit": 999}, + ) + assertDryRunContains(t, dryRunFieldList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields", "offset=0", "limit=200") + + rt := newBaseTestRuntime( + map[string]string{ + "base-token": "app_x", + "table-id": "tbl_1", + "field-id": "fld_1", + "json": `{"name":"Amount","type":"number"}`, + "keyword": " open ", + }, + nil, + map[string]int{"offset": 3, "limit": 0}, + ) + assertDryRunContains(t, dryRunFieldGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1") + assertDryRunContains(t, dryRunFieldCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/fields") + assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1") + assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1") + assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open") +} + +func TestDryRunRecordOps(t *testing.T) { + ctx := context.Background() + + listRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1"}, + nil, + map[string]int{"offset": -3, "limit": 500}, + ) + assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1") + + upsertCreateRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`}, + nil, nil, + ) + assertDryRunContains(t, dryRunRecordUpsert(ctx, upsertCreateRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records") + + rt := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "record-id": "rec_1", "json": `{"Name":"B"}`}, + nil, + map[string]int{"max-version": 11, "page-size": 30}, + ) + assertDryRunContains(t, dryRunRecordGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1") + assertDryRunContains(t, dryRunRecordUpsert(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1") + assertDryRunContains(t, dryRunRecordDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1") + assertDryRunContains(t, dryRunRecordHistoryList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/record_history", "max_version=11", "page_size=30", "record_id=rec_1", "table_id=tbl_1") + + uploadAttachmentRT := newBaseTestRuntime( + map[string]string{ + "base-token": "app_x", + "table-id": "tbl_1", + "record-id": "rec_1", + "field-id": "fld_att", + "file": "/tmp/report.pdf", + "name": "report-final.pdf", + }, + nil, + nil, + ) + assertDryRunContains(t, + BaseRecordUploadAttachment.DryRun(ctx, uploadAttachmentRT), + "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_att", + "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1", + "POST /open-apis/drive/v1/medias/upload_all", + "bitable_file", + "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1", + "report-final.pdf", + "deprecated_set_attachment", + ) +} + +func TestDryRunBaseOps(t *testing.T) { + ctx := context.Background() + + getRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + assertDryRunContains(t, dryRunBaseGet(ctx, getRT), "GET /open-apis/base/v3/bases/app_x") + + copyRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "name": "Copied", "folder-token": "fld_x", "time-zone": "Asia/Shanghai"}, + map[string]bool{"without-content": true}, + nil, + ) + assertDryRunContains(t, dryRunBaseCopy(ctx, copyRT), "POST /open-apis/base/v3/bases/app_x/copy") + + createRT := newBaseTestRuntime( + map[string]string{"name": "New Base", "folder-token": "fld_y", "time-zone": "Asia/Shanghai"}, + nil, + nil, + ) + assertDryRunContains(t, dryRunBaseCreate(ctx, createRT), "POST /open-apis/base/v3/bases") +} + +func TestDryRunDashboardOps(t *testing.T) { + ctx := context.Background() + + rt := newBaseTestRuntime( + map[string]string{ + "base-token": "app_x", + "dashboard-id": "dash_1", + "block-id": "blk_1", + "name": "Main", + "theme-style": "light", + "type": "bar", + "data-config": `{"table_name":"orders"}`, + "user-id-type": "open_id", + "page-size": "50", + "page-token": "pt_1", + }, + nil, + nil, + ) + + assertDryRunContains(t, dryRunDashboardList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards", "page_size=50", "page_token=pt_1") + assertDryRunContains(t, dryRunDashboardGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards/dash_1") + assertDryRunContains(t, dryRunDashboardCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/dashboards") + assertDryRunContains(t, dryRunDashboardUpdate(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/dashboards/dash_1") + assertDryRunContains(t, dryRunDashboardDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/dashboards/dash_1") + + assertDryRunContains(t, dryRunDashboardBlockList(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks", "page_size=50", "page_token=pt_1") + assertDryRunContains(t, dryRunDashboardBlockGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks/blk_1", "user_id_type=open_id") + assertDryRunContains(t, dryRunDashboardBlockCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks", "user_id_type=open_id") + assertDryRunContains(t, dryRunDashboardBlockUpdate(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks/blk_1", "user_id_type=open_id") + assertDryRunContains(t, dryRunDashboardBlockDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/dashboards/dash_1/blocks/blk_1") +} + +func TestDryRunViewOps(t *testing.T) { + ctx := context.Background() + + listRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1"}, + nil, + map[string]int{"offset": -1, "limit": 500}, + ) + assertDryRunContains(t, dryRunViewList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views", "offset=0", "limit=200") + assertDryRunContains(t, dryRunViewGet(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1") + assertDryRunContains(t, dryRunViewDelete(ctx, listRT), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1") + + createValidRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `[{"name":"Main"}]`}, + nil, nil, + ) + assertDryRunContains(t, dryRunViewCreate(ctx, createValidRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/views") + + createInvalidRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{`}, + nil, nil, + ) + assertDryRunContains(t, dryRunViewCreate(ctx, createInvalidRT), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/views") + + setJSONObjectRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1", "json": `{"enabled":true}`, "name": "New View"}, + nil, nil, + ) + assertDryRunContains(t, dryRunViewSetFilter(ctx, setJSONObjectRT), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/filter") + assertDryRunContains(t, dryRunViewSetTimebar(ctx, setJSONObjectRT), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/timebar") + assertDryRunContains(t, dryRunViewSetCard(ctx, setJSONObjectRT), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/card") + assertDryRunContains(t, dryRunViewRename(ctx, setJSONObjectRT), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1") + + setWrappedRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1", "json": `[{"field":"fld_status"}]`}, + nil, nil, + ) + assertDryRunContains(t, dryRunViewSetGroup(ctx, setWrappedRT), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/group") + assertDryRunContains(t, dryRunViewSetSort(ctx, setWrappedRT), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/sort") + + setWrappedInvalidRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "table-id": "tbl_1", "view-id": "viw_1", "json": `{`}, + nil, nil, + ) + assertDryRunContains(t, dryRunViewSetWrapped(setWrappedInvalidRT, "group", "group_config"), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/group") + + assertDryRunContains(t, dryRunViewGetFilter(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/filter") + assertDryRunContains(t, dryRunViewGetGroup(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/group") + assertDryRunContains(t, dryRunViewGetSort(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/sort") + assertDryRunContains(t, dryRunViewGetTimebar(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/timebar") + assertDryRunContains(t, dryRunViewGetCard(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/card") + + assertDryRunContains(t, dryRunViewGetProperty(listRT, "a/b"), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/views/viw_1/a%2Fb") +} diff --git a/shortcuts/base/base_errors.go b/shortcuts/base/base_errors.go new file mode 100644 index 00000000..718c15d3 --- /dev/null +++ b/shortcuts/base/base_errors.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" +) + +func handleBaseAPIResult(result interface{}, err error, action string) (map[string]interface{}, error) { + data, err := handleBaseAPIResultAny(result, err, action) + if err != nil { + return nil, err + } + dataMap, _ := data.(map[string]interface{}) + return dataMap, nil +} + +func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) { + if err != nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err) + } + + resultMap, _ := result.(map[string]interface{}) + code, _ := util.ToFloat64(resultMap["code"]) + if code == 0 { + return resultMap["data"], nil + } + + larkCode := int(code) + msg := extractDataErrorMessage(resultMap) + if strings.TrimSpace(msg) == "" { + msg, _ = resultMap["msg"].(string) + } + + fullMsg := fmt.Sprintf("%s: [%d] %s", action, larkCode, msg) + detail := extractErrorDetail(resultMap) + apiErr := output.ErrAPI(larkCode, fullMsg, detail) + if apiErr.Detail != nil && apiErr.Detail.Hint == "" { + if hint := extractErrorHint(resultMap); hint != "" { + apiErr.Detail.Hint = hint + } + } + return nil, apiErr +} + +func extractErrorDetail(resultMap map[string]interface{}) interface{} { + if detail, ok := nonNilMapValue(resultMap, "error"); ok { + return detail + } + data, _ := resultMap["data"].(map[string]interface{}) + if detail, ok := nonNilMapValue(data, "error"); ok { + return detail + } + return nil +} + +func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) { + if src == nil { + return nil, false + } + value, ok := src[key] + if !ok { + return nil, false + } + switch value.(type) { + case nil: + return nil, false + default: + return value, true + } +} + +func extractErrorHint(resultMap map[string]interface{}) string { + if detail, ok := resultMap["error"].(map[string]interface{}); ok { + if hint, _ := detail["hint"].(string); strings.TrimSpace(hint) != "" { + return hint + } + } + data, _ := resultMap["data"].(map[string]interface{}) + if detail, ok := data["error"].(map[string]interface{}); ok { + if hint, _ := detail["hint"].(string); strings.TrimSpace(hint) != "" { + return hint + } + } + return "" +} + +func extractDataErrorMessage(resultMap map[string]interface{}) string { + data, _ := resultMap["data"].(map[string]interface{}) + if detail, ok := data["error"].(map[string]interface{}); ok { + if message, _ := detail["message"].(string); strings.TrimSpace(message) != "" { + return message + } + } + return "" +} diff --git a/shortcuts/base/base_errors_test.go b/shortcuts/base/base_errors_test.go new file mode 100644 index 00000000..5b86c8ae --- /dev/null +++ b/shortcuts/base/base_errors_test.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + "testing" +) + +func TestErrorDetailHelpers(t *testing.T) { + if value, ok := nonNilMapValue(nil, "error"); ok || value != nil { + t.Fatalf("nil map should not return value") + } + if value, ok := nonNilMapValue(map[string]interface{}{"error": nil}, "error"); ok || value != nil { + t.Fatalf("nil entry should not return value") + } + detail := map[string]interface{}{"message": "boom", "hint": "retry later"} + if value, ok := nonNilMapValue(map[string]interface{}{"error": detail}, "error"); !ok || value == nil { + t.Fatalf("expected non-nil detail") + } + if got := extractErrorDetail(map[string]interface{}{"error": detail}); got == nil { + t.Fatalf("expected root detail") + } + if got := extractErrorDetail(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got == nil { + t.Fatalf("expected nested detail") + } + if got := extractErrorHint(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got != "retry later" { + t.Fatalf("hint=%q", got) + } + if got := extractDataErrorMessage(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got != "boom" { + t.Fatalf("message=%q", got) + } + if got := extractDataErrorMessage(map[string]interface{}{"data": map[string]interface{}{}}); got != "" { + t.Fatalf("message=%q", got) + } +} + +func TestHandleBaseAPIResultErrorPaths(t *testing.T) { + if _, err := handleBaseAPIResultAny(nil, assertErr{}, "list fields"); err == nil || !strings.Contains(err.Error(), "list fields") { + t.Fatalf("err=%v", err) + } + result := map[string]interface{}{ + "code": 190001, + "msg": "bad request", + "data": map[string]interface{}{ + "error": map[string]interface{}{"message": "invalid filter", "hint": "check field name"}, + }, + } + if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") || !strings.Contains(err.Error(), "190001") { + t.Fatalf("err=%v", err) + } + if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil { + t.Fatalf("expected error") + } +} + +type assertErr struct{} + +func (assertErr) Error() string { return "network timeout" } diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go new file mode 100644 index 00000000..34128a93 --- /dev/null +++ b/shortcuts/base/base_execute_test.go @@ -0,0 +1,1047 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + config := &core.CliConfig{ + AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_testuser", + } + factory, stdout, _, reg := cmdutil.TestFactory(t, config) + return factory, stdout, reg +} + +func registerTokenStub(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, + "tenant_access_token": "t-test-token", + "expire": 7200, + }, + }) +} + +func withBaseWorkingDir(t *testing.T, dir string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() err=%v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) err=%v", dir, err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatalf("restore cwd err=%v", err) + } + }) +} + +func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + shortcut.AuthTypes = []string{"bot"} + parent := &cobra.Command{Use: "base"} + shortcut.Mount(parent, factory) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + stdout.Reset() + return parent.ExecuteContext(context.Background()) +} + +func TestBaseWorkspaceExecuteCreate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"}, + }, + }) + if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"app_token": "app_x"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) { + t.Run("get", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"base_token": "app_x", "name": "Demo Base"}, + }, + }) + if err := runShortcut(t, BaseBaseGet, []string{"+base-get", "--base-token", "app_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"base"`) || !strings.Contains(got, `"Demo Base"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("copy", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_src/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base", "url": "https://example.com/base/app_new"}, + }, + }) + args := []string{"+base-copy", "--base-token", "app_src", "--name", "Copied Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai", "--without-content"} + if err := runShortcut(t, BaseBaseCopy, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"copied": true`) || !strings.Contains(got, `"app_new"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseHistoryExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/record_history", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"record_id": "rec_x"}}}, + }, + }) + if err := runShortcut(t, BaseRecordHistoryList, []string{"+record-history-list", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_x", "--page-size", "10"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_id": "rec_x"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseFieldExecuteUpdate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_x", "name": "Amount", "type": "number"}, + }, + }) + if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseTableExecuteCreate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_new", "name": "Orders"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{map[string]interface{}{"id": "fld_primary", "name": "Primary"}}}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/fields/fld_primary", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_primary", "name": "OrderNo", "type": "text"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/views", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "vew_main", "name": "Main", "type": "grid"}, + }, + }) + args := []string{"+table-create", "--base-token", "app_x", "--name", "Orders", "--fields", `[{"name":"OrderNo","type":"text"}]`, "--view", `{"name":"Main","type":"grid"}`} + if err := runShortcut(t, BaseTableCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"table"`) || !strings.Contains(got, `"vew_main"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseTableExecuteUpdate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_x", "name": "Orders Updated"}, + }, + }) + if err := runShortcut(t, BaseTableUpdate, []string{"+table-update", "--base-token", "app_x", "--table-id", "tbl_x", "--name", "Orders Updated"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"Orders Updated"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseRecordExecuteUpsertUpdate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"record_id": "rec_x", "fields": map[string]interface{}{"Name": "Alice"}}, + }, + }) + if err := runShortcut(t, BaseRecordUpsert, []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_x", "--json", `{"fields":{"Name":"Alice"}}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"rec_x"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseViewExecuteRename(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "vew_x", "name": "Renamed", "type": "grid"}, + }, + }) + if err := runShortcut(t, BaseViewRename, []string{"+view-rename", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--name", "Renamed"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"Renamed"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseViewExecutePropertyActions(t *testing.T) { + t.Run("set-group", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/group", + Body: map[string]interface{}{ + "code": 0, + "data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}}, + }, + }) + if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_status","desc":false}]`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("set-sort", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/sort", + Body: map[string]interface{}{ + "code": 0, + "data": []interface{}{map[string]interface{}{"field": "fld_amount", "desc": true}}, + }, + }) + if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_amount","desc":true}]`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_amount"`) { + t.Fatalf("stdout=%s", got) + } + }) + +} + +func TestBaseFieldExecuteCRUD(t *testing.T) { + t.Run("list", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "limit=1&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{ + map[string]interface{}{"id": "fld_2", "name": "Amount", "type": "number"}, + }, "total": 2}, + }, + }) + if err := runShortcut(t, BaseFieldList, []string{"+field-list", "--base-token", "app_x", "--table-id", "tbl_x", "--offset", "0", "--limit", "1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"field_name": "Amount"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_x", "name": "Amount", "type": "number"}, + }, + }) + if err := runShortcut(t, BaseFieldGet, []string{"+field-get", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"field"`) || !strings.Contains(got, `"fld_x"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("create", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_new", "name": "Status", "type": "text"}, + }, + }) + if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Status","type":"text"}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("delete", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseFieldDelete, []string{"+field-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"field_id": "fld_x"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseTableExecuteReadAndDelete(t *testing.T) { + t.Run("list", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "limit=1&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_a", "name": "Alpha"}, + }, "total": 2}, + }, + }) + if err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x", "--limit", "1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"table_name": "Alpha"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("list-http-404", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Status: 404, + Body: "404 page not found", + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + }, + }) + err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "HTTP 404") || !strings.Contains(err.Error(), "404 page not found") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("get", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_x", "name": "Orders", "primary_field": "fld_x"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"fields": []interface{}{map[string]interface{}{"id": "fld_x", "name": "OrderNo", "type": "text"}}}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"views": []interface{}{map[string]interface{}{"id": "vew_x", "name": "Main", "type": "grid"}}}, + }, + }) + if err := runShortcut(t, BaseTableGet, []string{"+table-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"name": "Orders"`) || !strings.Contains(got, `"primary_field": "fld_x"`) || !strings.Contains(got, `"vew_x"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("delete", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseTableDelete, []string{"+table-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"table_id": "tbl_x"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { + t.Run("list", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "limit=1&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"records": map[string]interface{}{ + "schema": []interface{}{"Name", "Age"}, + "record_ids": []interface{}{"rec_1"}, + "rows": []interface{}{[]interface{}{"Alice", 18}}, + }}, + }, + }) + if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"records"`) || !strings.Contains(got, `"Alice"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("list new shape", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "limit=1&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "fields": []interface{}{"Name", "Age"}, + "record_id_list": []interface{}{"rec_2"}, + "data": []interface{}{[]interface{}{"Bob", 20}}, + "total": 1, + }, + }, + }) + if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Bob"`) || !strings.Contains(got, `"rec_2"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"records": map[string]interface{}{ + "schema": []interface{}{"Name", "Age"}, + "record_ids": []interface{}{"rec_1"}, + "rows": []interface{}{[]interface{}{"Alice", 18}}, + }}, + }, + }) + if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"record_ids"`) || !strings.Contains(got, `"Name"`) || strings.Contains(got, `"raw"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get passthrough fallback", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_2", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"unexpected": "shape", "record_id": "rec_2"}, + }, + }) + if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"unexpected": "shape"`) || strings.Contains(got, `"raw"`) || strings.Contains(got, `"record":`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("create", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"record_id": "rec_new", "fields": map[string]interface{}{"Name": "Alice"}}, + }, + }) + if err := runShortcut(t, BaseRecordUpsert, []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":{"Name":"Alice"}}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"rec_new"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("delete", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_1", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"record_id": "rec_1"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("upload attachment", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + + tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.txt") + if err != nil { + t.Fatalf("CreateTemp() err=%v", err) + } + if _, err := tmpFile.WriteString("hello attachment"); err != nil { + t.Fatalf("WriteString() err=%v", err) + } + if err := tmpFile.Close(); err != nil { + t.Fatalf("Close() err=%v", err) + } + withBaseWorkingDir(t, filepath.Dir(tmpFile.Name())) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id": "rec_x", + "fields": map[string]interface{}{ + "附件": []interface{}{ + map[string]interface{}{ + "file_token": "existing_tok", + "name": "existing.pdf", + "size": 2048, + "image_width": 640, + "image_height": 480, + "deprecated_set_attachment": false, + }, + }, + }, + }, + }, + }) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "file_tok_1"}, + }, + } + reg.Register(uploadStub) + updateStub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "record_id": "rec_x", + "fields": map[string]interface{}{ + "附件": []interface{}{ + map[string]interface{}{ + "file_token": "existing_tok", + "name": "existing.pdf", + "size": 2048, + "image_width": 640, + "image_height": 480, + "deprecated_set_attachment": true, + }, + map[string]interface{}{ + "file_token": "file_tok_1", + "name": "report.txt", + "deprecated_set_attachment": true, + }, + }, + }, + }, + }, + } + reg.Register(updateStub) + + if err := runShortcut(t, BaseRecordUploadAttachment, []string{ + "+record-upload-attachment", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--record-id", "rec_x", + "--field-id", "fld_att", + "--file", "./" + filepath.Base(tmpFile.Name()), + "--name", "report.txt", + }, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_1"`) || !strings.Contains(got, `"report.txt"`) { + t.Fatalf("stdout=%s", got) + } + + uploadBody := string(uploadStub.CapturedBody) + if !strings.Contains(uploadBody, `name="parent_type"`) || !strings.Contains(uploadBody, "bitable_file") || !strings.Contains(uploadBody, `name="parent_node"`) || !strings.Contains(uploadBody, "app_x") { + t.Fatalf("upload body=%s", uploadBody) + } + + updateBody := string(updateStub.CapturedBody) + if !strings.Contains(updateBody, `"附件"`) || + !strings.Contains(updateBody, `"file_token":"existing_tok"`) || + !strings.Contains(updateBody, `"name":"existing.pdf"`) || + !strings.Contains(updateBody, `"size":2048`) || + !strings.Contains(updateBody, `"image_width":640`) || + !strings.Contains(updateBody, `"image_height":480`) || + !strings.Contains(updateBody, `"deprecated_set_attachment":true`) || + !strings.Contains(updateBody, `"file_token":"file_tok_1"`) || + !strings.Contains(updateBody, `"name":"report.txt"`) { + t.Fatalf("update body=%s", updateBody) + } + }) + + t.Run("upload attachment rejects non-attachment field", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + + tmpFile, err := os.CreateTemp(t.TempDir(), "base-not-attachment-*.txt") + if err != nil { + t.Fatalf("CreateTemp() err=%v", err) + } + if _, err := tmpFile.WriteString("hello"); err != nil { + t.Fatalf("WriteString() err=%v", err) + } + if err := tmpFile.Close(); err != nil { + t.Fatalf("Close() err=%v", err) + } + withBaseWorkingDir(t, filepath.Dir(tmpFile.Name())) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_status", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_status", "name": "状态", "type": "text"}, + }, + }) + + err = runShortcut(t, BaseRecordUploadAttachment, []string{ + "+record-upload-attachment", + "--base-token", "app_x", + "--table-id", "tbl_x", + "--record-id", "rec_x", + "--field-id", "fld_status", + "--file", "./" + filepath.Base(tmpFile.Name()), + }, factory, stdout) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "expected attachment") { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) { + t.Run("list", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "limit=1&offset=0", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"views": []interface{}{map[string]interface{}{"id": "vew_1", "name": "Main", "type": "grid"}}, "total": 3}, + }, + }) + if err := runShortcut(t, BaseViewList, []string{"+view-list", "--base-token", "app_x", "--table-id", "tbl_x", "--offset", "0", "--limit", "1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"total": 3`) || !strings.Contains(got, `"view_name": "Main"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "vew_1", "name": "Main", "type": "grid"}, + }, + }) + if err := runShortcut(t, BaseViewGet, []string{"+view-get", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"view"`) || !strings.Contains(got, `"vew_1"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("create", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "vew_1", "name": "Main", "type": "grid"}, + }, + }) + if err := runShortcut(t, BaseViewCreate, []string{"+view-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Main","type":"grid"}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"views"`) || !strings.Contains(got, `"vew_1"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("delete", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseViewDelete, []string{"+view-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"view_id": "vew_1"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("set-filter", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1/filter", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"conditions": []interface{}{map[string]interface{}{"field_name": "Status"}}}, + }, + }) + if err := runShortcut(t, BaseViewSetFilter, []string{"+view-set-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1", "--json", `{"conditions":[{"field_name":"Status"}]}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"filter"`) || !strings.Contains(got, `"Status"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseTableExecuteListFallbackShapes(t *testing.T) { + t.Run("items-payload", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"id": "tbl_items", "name": "ItemsOnly"}}}, + }, + }) + if err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"ItemsOnly"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("single-object-payload", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_single", "name": "SingleOnly"}, + }, + }) + if err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"SingleOnly"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseRecordExecuteListWithViewPagination(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "view_id=vew_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"records": map[string]interface{}{ + "schema": []interface{}{"Name", "Index"}, + "record_ids": []interface{}{"rec_last"}, + "rows": []interface{}{[]interface{}{"Tail", 200}}, + }, "total": 201}, + }, + }) + if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"rec_last"`) || !strings.Contains(got, `"total": 201`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseHistoryExecuteWithLinkFieldLimit(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "max_version=2", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"record_id": "rec_x", "field_name": "History"}}}, + }, + }) + if err := runShortcut(t, BaseRecordHistoryList, []string{"+record-history-list", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_x", "--page-size", "10", "--max-version", "2"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"field_name": "History"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseFieldExecuteSearchOptions(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_amount/options", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"options": []interface{}{map[string]interface{}{"id": "opt_1", "name": "已完成"}}, "total": 1}, + }, + }) + if err := runShortcut(t, BaseFieldSearchOptions, []string{"+field-search-options", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_amount", "--keyword", "已", "--limit", "10"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"options"`) || !strings.Contains(got, `"已完成"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseViewExecutePropertyGettersAndExtendedSetters(t *testing.T) { + t.Run("get-group", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/group", Body: map[string]interface{}{"code": 0, "data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}}}}) + if err := runShortcut(t, BaseViewGetGroup, []string{"+view-get-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get-filter", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/filter", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"conditions": []interface{}{map[string]interface{}{"field_name": "Status"}}}}}) + if err := runShortcut(t, BaseViewGetFilter, []string{"+view-get-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"filter"`) || !strings.Contains(got, `"Status"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get-sort", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/sort", Body: map[string]interface{}{"code": 0, "data": []interface{}{map[string]interface{}{"field": "fld_priority", "desc": true}}}}) + if err := runShortcut(t, BaseViewGetSort, []string{"+view-get-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_priority"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get-timebar", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_time/timebar", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"start_time": "fld_start", "end_time": "fld_end", "title": "fld_title"}}}) + if err := runShortcut(t, BaseViewGetTimebar, []string{"+view-get-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_time"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"timebar"`) || !strings.Contains(got, `"fld_start"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("set-timebar", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "PUT", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_time/timebar", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"start_time": "fld_start", "end_time": "fld_end", "title": "fld_title"}}}) + args := []string{"+view-set-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_time", "--json", `{"start_time":"fld_start","end_time":"fld_end","title":"fld_title"}`} + if err := runShortcut(t, BaseViewSetTimebar, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"timebar"`) || !strings.Contains(got, `"fld_end"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("get-card", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_card/card", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"cover_field": "fld_cover"}}}) + if err := runShortcut(t, BaseViewGetCard, []string{"+view-get-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_card"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"card"`) || !strings.Contains(got, `"fld_cover"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("set-card", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{Method: "PUT", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_card/card", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"cover_field": "fld_cover"}}}) + if err := runShortcut(t, BaseViewSetCard, []string{"+view-set-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_card", "--json", `{"cover_field":"fld_cover"}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"card"`) || !strings.Contains(got, `"fld_cover"`) { + t.Fatalf("stdout=%s", got) + } + }) +} diff --git a/shortcuts/base/base_form_create.go b/shortcuts/base/base_form_create.go new file mode 100644 index 00000000..978d2446 --- /dev/null +++ b/shortcuts/base/base_form_create.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormCreate = common.Shortcut{ + Service: "base", + Command: "+form-create", + Description: "Create a form in a Base table", + Risk: "write", + Scopes: []string{"base:form:create"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "name", Desc: "form name", Required: true}, + {Name: "description", Desc: `form description (plain text or markdown link like [text](https://example.com))`}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + name := runtime.Str("name") + description := runtime.Str("description") + + body := map[string]interface{}{"name": name} + if description != "" { + body["description"] = description + } + + data, err := baseV3Call(runtime, "POST", + baseV3Path("bases", baseToken, "tables", tableId, "forms"), nil, body) + if err != nil { + return err + } + + runtime.OutFormat(data, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{ + { + "id": data["id"], + "name": data["name"], + "description": data["description"], + }, + }) + }) + return nil + }, +} diff --git a/shortcuts/base/base_form_delete.go b/shortcuts/base/base_form_delete.go new file mode 100644 index 00000000..514e5acf --- /dev/null +++ b/shortcuts/base/base_form_delete.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormDelete = common.Shortcut{ + Service: "base", + Command: "+form-delete", + Description: "Delete a form in a Base table", + Risk: "high-risk-write", + Scopes: []string{"base:form:delete"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base app token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + + _, err := baseV3Call(runtime, "DELETE", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId), nil, nil) + if err != nil { + return err + } + + runtime.Out(map[string]interface{}{"deleted": true, "form_id": formId}, nil) + return nil + }, +} diff --git a/shortcuts/base/base_form_execute_test.go b/shortcuts/base/base_form_execute_test.go new file mode 100644 index 00000000..668a830f --- /dev/null +++ b/shortcuts/base/base_form_execute_test.go @@ -0,0 +1,364 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestBaseFormExecuteList(t *testing.T) { + t.Run("single page", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "total": 2, + "forms": []interface{}{ + map[string]interface{}{"id": "vew_form1", "name": "用户调研问卷", "description": "2024年调研"}, + map[string]interface{}{"id": "vew_form2", "name": "产品反馈表", "description": ""}, + }, + }, + }, + }) + if err := runShortcut(t, BaseFormsList, []string{"+form-list", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form1"`) || !strings.Contains(got, `"total": 2`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("auto pagination", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + // First page: has_more=true + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": true, + "page_token": "tok_p2", + "total": 2, + "forms": []interface{}{ + map[string]interface{}{"id": "vew_form1", "name": "Page1 Form", "description": ""}, + }, + }, + }, + }) + // Second page: has_more=false + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "page_token=tok_p2", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "total": 2, + "forms": []interface{}{ + map[string]interface{}{"id": "vew_form2", "name": "Page2 Form", "description": ""}, + }, + }, + }, + }) + if err := runShortcut(t, BaseFormsList, []string{"+form-list", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"vew_form1"`) || !strings.Contains(got, `"vew_form2"`) { + t.Fatalf("stdout=%s", got) + } + if !strings.Contains(got, `"total": 2`) { + t.Fatalf("expected total=2 in stdout=%s", got) + } + }) +} + +func TestBaseFormExecuteGet(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form1", + "name": "用户调研问卷", + "description": "2024年度用户满意度调研", + }, + }, + }) + if err := runShortcut(t, BaseFormGet, []string{"+form-get", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form1"`) || !strings.Contains(got, `"用户调研问卷"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseFormExecuteCreate(t *testing.T) { + t.Run("name only", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form_new", + "name": "新建表单", + "description": "", + }, + }, + }) + if err := runShortcut(t, BaseFormCreate, []string{"+form-create", "--base-token", "app_x", "--table-id", "tbl_x", "--name", "新建表单"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form_new"`) || !strings.Contains(got, `"新建表单"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("with description", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form_desc", + "name": "含描述表单", + "description": "这是表单说明", + }, + }, + }) + args := []string{"+form-create", "--base-token", "app_x", "--table-id", "tbl_x", "--name", "含描述表单", + "--description", "这是表单说明"} + if err := runShortcut(t, BaseFormCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form_desc"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("with description link", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form_link", + "name": "含链接表单", + "description": "更多信息请查看[这里](https://example.com)", + }, + }, + }) + args := []string{"+form-create", "--base-token", "app_x", "--table-id", "tbl_x", "--name", "含链接表单", + "--description", "更多信息请查看[这里](https://example.com)"} + if err := runShortcut(t, BaseFormCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form_link"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseFormExecuteUpdate(t *testing.T) { + t.Run("update name", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form1", + "name": "更新后的表单", + "description": "", + }, + }, + }) + if err := runShortcut(t, BaseFormUpdate, []string{"+form-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", "--name", "更新后的表单"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form1"`) || !strings.Contains(got, `"更新后的表单"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("update with description", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "id": "vew_form1", + "name": "Form", + "description": "更新的描述内容", + }, + }, + }) + args := []string{"+form-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--description", "更新的描述内容"} + if err := runShortcut(t, BaseFormUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"vew_form1"`) { + t.Fatalf("stdout=%s", got) + } + }) +} + +func TestBaseFormExecuteDelete(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runShortcut(t, BaseFormDelete, []string{"+form-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", "--yes"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"form_id": "vew_form1"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseFormQuestionsExecuteList(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "total": 2, + "questions": []interface{}{ + map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil}, + map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil}, + }, + }, + }, + }) + if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseFormQuestionsExecuteCreate(t *testing.T) { + t.Run("create questions", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "questions": []interface{}{ + map[string]interface{}{"id": "q_new1", "title": "您的姓名", "required": true}, + }, + }, + }, + }) + args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--questions", `[{"type":"text","title":"您的姓名","required":true}]`} + if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_new1"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("invalid questions json", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--questions", `not-an-array`} + if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err == nil { + t.Fatalf("expected error for invalid questions JSON") + } + }) +} + +func TestBaseFormQuestionsExecuteUpdate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "questions": []interface{}{ + map[string]interface{}{"id": "q_001", "title": "更新后的问题", "required": true}, + }, + }, + }, + }) + args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`} + if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseFormQuestionsExecuteDelete(t *testing.T) { + t.Run("delete questions", func(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + args := []string{"+form-questions-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--question-ids", `["q_001","q_002"]`, "--yes"} + if err := runShortcut(t, BaseFormQuestionsDelete, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"q_001"`) { + t.Fatalf("stdout=%s", got) + } + }) + + t.Run("invalid question-ids json", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+form-questions-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1", + "--question-ids", `not-json`} + if err := runShortcut(t, BaseFormQuestionsDelete, args, factory, stdout); err == nil { + t.Fatalf("expected error for invalid question-ids JSON") + } + }) +} diff --git a/shortcuts/base/base_form_get.go b/shortcuts/base/base_form_get.go new file mode 100644 index 00000000..ca44dac4 --- /dev/null +++ b/shortcuts/base/base_form_get.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormGet = common.Shortcut{ + Service: "base", + Command: "+form-get", + Description: "Get a form in a Base table", + Risk: "read", + Scopes: []string{"base:form:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base app token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + + data, err := baseV3Call(runtime, "GET", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId), nil, nil) + if err != nil { + return err + } + + runtime.OutFormat(data, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{ + { + "id": data["id"], + "name": data["name"], + "description": data["description"], + }, + }) + }) + return nil + }, +} diff --git a/shortcuts/base/base_form_list.go b/shortcuts/base/base_form_list.go new file mode 100644 index 00000000..07ead6e5 --- /dev/null +++ b/shortcuts/base/base_form_list.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormsList = common.Shortcut{ + Service: "base", + Command: "+form-list", + Description: "List all forms in a Base table (auto-paginated)", + Risk: "read", + Scopes: []string{"base:form:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + + var allForms []interface{} + pageToken := "" + for { + params := map[string]interface{}{ + "page_size": runtime.Int("page-size"), + } + if pageToken != "" { + params["page_token"] = pageToken + } + + data, err := baseV3Call(runtime, "GET", + baseV3Path("bases", baseToken, "tables", tableId, "forms"), params, nil) + if err != nil { + return err + } + + forms, _ := data["forms"].([]interface{}) + allForms = append(allForms, forms...) + + hasMore, _ := data["has_more"].(bool) + if !hasMore { + break + } + nextToken, _ := data["page_token"].(string) + if nextToken == "" { + break + } + pageToken = nextToken + } + + outData := map[string]interface{}{ + "forms": allForms, + "total": len(allForms), + } + runtime.OutFormat(outData, nil, func(w io.Writer) { + if len(allForms) == 0 { + fmt.Fprintln(w, "No forms found.") + return + } + var rows []map[string]interface{} + for _, item := range allForms { + m, _ := item.(map[string]interface{}) + rows = append(rows, map[string]interface{}{ + "id": m["id"], + "name": m["name"], + "description": m["description"], + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d form(s) total\n", len(allForms)) + }) + return nil + }, +} diff --git a/shortcuts/base/base_form_questions_create.go b/shortcuts/base/base_form_questions_create.go new file mode 100644 index 00000000..c9c79642 --- /dev/null +++ b/shortcuts/base/base_form_questions_create.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormQuestionsCreate = common.Shortcut{ + Service: "base", + Command: "+form-questions-create", + Description: "Create questions for a form in a Base table", + Risk: "write", + Scopes: []string{"base:form:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + {Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + questionsJSON := runtime.Str("questions") + + var questions []interface{} + if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil { + return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err) + } + + data, err := baseV3Call(runtime, "POST", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId, "questions"), + nil, map[string]interface{}{"questions": questions}) + if err != nil { + return err + } + + items, _ := data["questions"].([]interface{}) + outData := map[string]interface{}{"questions": items} + + runtime.OutFormat(outData, nil, func(w io.Writer) { + var rows []map[string]interface{} + for _, item := range items { + m, _ := item.(map[string]interface{}) + rows = append(rows, map[string]interface{}{ + "id": m["id"], + "title": m["title"], + "required": m["required"], + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d question(s) created\n", len(items)) + }) + return nil + }, +} diff --git a/shortcuts/base/base_form_questions_delete.go b/shortcuts/base/base_form_questions_delete.go new file mode 100644 index 00000000..4a3b0387 --- /dev/null +++ b/shortcuts/base/base_form_questions_delete.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormQuestionsDelete = common.Shortcut{ + Service: "base", + Command: "+form-questions-delete", + Description: "Delete questions from a form in a Base table", + Risk: "high-risk-write", + Scopes: []string{"base:form:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + {Name: "question-ids", Desc: `JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`, Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + questionIdsJSON := runtime.Str("question-ids") + + var questionIds []string + if err := json.Unmarshal([]byte(questionIdsJSON), &questionIds); err != nil { + return output.Errorf(output.ExitValidation, "invalid_json", "--question-ids must be a valid JSON array of strings: %s", err) + } + + _, err := baseV3Call(runtime, "DELETE", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId, "questions"), + nil, map[string]interface{}{"question_ids": questionIds}) + if err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "deleted": true, + "question_ids": questionIds, + }, nil) + return nil + }, +} diff --git a/shortcuts/base/base_form_questions_list.go b/shortcuts/base/base_form_questions_list.go new file mode 100644 index 00000000..ad73c7b4 --- /dev/null +++ b/shortcuts/base/base_form_questions_list.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormQuestionsList = common.Shortcut{ + Service: "base", + Command: "+form-questions-list", + Description: "List questions of a form in a Base table", + Risk: "read", + Scopes: []string{"base:form:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base app token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + + data, err := baseV3Call(runtime, "GET", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId, "questions"), nil, nil) + if err != nil { + return err + } + + items, _ := data["questions"].([]interface{}) + outData := map[string]interface{}{ + "questions": items, + "total": data["total"], + } + + runtime.OutFormat(outData, nil, func(w io.Writer) { + if len(items) == 0 { + fmt.Fprintln(w, "No questions found.") + return + } + var rows []map[string]interface{} + for _, item := range items { + m, _ := item.(map[string]interface{}) + rows = append(rows, map[string]interface{}{ + "id": m["id"], + "title": m["title"], + "description": m["description"], + "required": m["required"], + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%v question(s) total\n", data["total"]) + }) + return nil + }, +} diff --git a/shortcuts/base/base_form_questions_update.go b/shortcuts/base/base_form_questions_update.go new file mode 100644 index 00000000..1abc35be --- /dev/null +++ b/shortcuts/base/base_form_questions_update.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormQuestionsUpdate = common.Shortcut{ + Service: "base", + Command: "+form-questions-update", + Description: "Update questions of a form in a Base table", + Risk: "write", + Scopes: []string{"base:form:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + {Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + questionsJSON := runtime.Str("questions") + + var questions []interface{} + if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil { + return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err) + } + + data, err := baseV3Call(runtime, "PATCH", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId, "questions"), + nil, map[string]interface{}{"questions": questions}) + if err != nil { + return err + } + + items, _ := data["items"].([]interface{}) + if len(items) == 0 { + items, _ = data["questions"].([]interface{}) + } + outData := map[string]interface{}{"questions": items} + + runtime.OutFormat(outData, nil, func(w io.Writer) { + var rows []map[string]interface{} + for _, item := range items { + m, _ := item.(map[string]interface{}) + rows = append(rows, map[string]interface{}{ + "id": m["id"], + "title": m["title"], + "required": m["required"], + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d question(s) updated\n", len(items)) + }) + return nil + }, +} diff --git a/shortcuts/base/base_form_update.go b/shortcuts/base/base_form_update.go new file mode 100644 index 00000000..53096e20 --- /dev/null +++ b/shortcuts/base/base_form_update.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFormUpdate = common.Shortcut{ + Service: "base", + Command: "+form-update", + Description: "Update a form in a Base table", + Risk: "write", + Scopes: []string{"base:form:update"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "Base token (base_token)", Required: true}, + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + {Name: "name", Desc: "new form name"}, + {Name: "description", Desc: "new form description (plain text or markdown link like [text](https://example.com))"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableId := runtime.Str("table-id") + formId := runtime.Str("form-id") + name := runtime.Str("name") + description := runtime.Str("description") + + body := map[string]interface{}{} + if name != "" { + body["name"] = name + } + if description != "" { + body["description"] = description + } + + data, err := baseV3Call(runtime, "PATCH", + baseV3Path("bases", baseToken, "tables", tableId, "forms", formId), nil, body) + if err != nil { + return err + } + + runtime.OutFormat(data, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{ + { + "id": data["id"], + "name": data["name"], + "description": data["description"], + }, + }) + }) + return nil + }, +} diff --git a/shortcuts/base/base_get.go b/shortcuts/base/base_get.go new file mode 100644 index 00000000..48e7080e --- /dev/null +++ b/shortcuts/base/base_get.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseBaseGet = common.Shortcut{ + Service: "base", + Command: "+base-get", + Description: "Get a base resource", + Risk: "read", + Scopes: []string{"base:app:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true)}, + DryRun: dryRunBaseGet, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseGet(runtime) + }, +} diff --git a/shortcuts/base/base_ops.go b/shortcuts/base/base_ops.go new file mode 100644 index 00000000..8a70cba7 --- /dev/null +++ b/shortcuts/base/base_ops.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token"). + Set("base_token", runtime.Str("base-token")) +} + +func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" { + body["folder_token"] = folderToken + } + if runtime.Bool("without-content") { + body["without_content"] = true + } + if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" { + body["time_zone"] = timeZone + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/copy"). + Body(body). + Set("base_token", runtime.Str("base-token")) +} + +func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{"name": runtime.Str("name")} + if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" { + body["folder_token"] = folderToken + } + if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" { + body["time_zone"] = timeZone + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases"). + Body(body) +} + +func executeBaseGet(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token")), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"base": data}, nil) + return nil +} + +func executeBaseCopy(runtime *common.RuntimeContext) error { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" { + body["folder_token"] = folderToken + } + if runtime.Bool("without-content") { + body["without_content"] = true + } + if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" { + body["time_zone"] = timeZone + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "copy"), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"base": data, "copied": true}, nil) + return nil +} + +func executeBaseCreate(runtime *common.RuntimeContext) error { + body := map[string]interface{}{"name": runtime.Str("name")} + if folderToken := strings.TrimSpace(runtime.Str("folder-token")); folderToken != "" { + body["folder_token"] = folderToken + } + if timeZone := strings.TrimSpace(runtime.Str("time-zone")); timeZone != "" { + body["time_zone"] = timeZone + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases"), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"base": data, "created": true}, nil) + return nil +} diff --git a/shortcuts/base/base_role_common.go b/shortcuts/base/base_role_common.go new file mode 100644 index 00000000..ab219091 --- /dev/null +++ b/shortcuts/base/base_role_common.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "encoding/json" + "fmt" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// handleRoleResponse parses the role API response. +// The response has two layers of code/message: +// - Outer: SDK-level code/msg (handled by DoAPI for transport errors) +// - Inner: business-level code/message inside the data object +// +// The data field may be a JSON object (actual behavior) or a JSON string (per doc). +func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action string) error { + var resp struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(rawBody, &resp); err != nil { + return fmt.Errorf("failed to parse response: %v", err) + } + if resp.Code != 0 { + msg := resp.Msg + // When outer msg is empty, try to extract error details from data.error.message + if msg == "" && len(resp.Data) > 0 { + var errData struct { + Error struct { + Message string `json:"message"` + Hint string `json:"hint"` + } `json:"error"` + } + if json.Unmarshal(resp.Data, &errData) == nil && errData.Error.Message != "" { + msg = errData.Error.Message + } + } + return output.ErrAPI(resp.Code, fmt.Sprintf("%s: [%d] %s", action, resp.Code, msg), nil) + } + + if len(resp.Data) == 0 || string(resp.Data) == "null" || string(resp.Data) == `""` { + runtime.Out(map[string]any{"success": true}, nil) + return nil + } + + // Parse data + var data any + if err := json.Unmarshal(resp.Data, &data); err != nil { + runtime.Out(map[string]any{"data": string(resp.Data)}, nil) + return nil + } + + // If data is a string (double-encoded JSON), try to parse it + if s, ok := data.(string); ok && s != "" { + var inner any + if err := json.Unmarshal([]byte(s), &inner); err == nil { + data = inner + } + } + + // Check for business-level error: data may contain its own code/message + if m, ok := data.(map[string]any); ok { + if code, exists := m["code"]; exists { + var codeInt int + switch v := code.(type) { + case float64: + codeInt = int(v) + case int: + codeInt = v + } + if codeInt != 0 { + msg, _ := m["message"].(string) + return output.ErrAPI(codeInt, fmt.Sprintf("%s: [%d] %s", action, codeInt, msg), nil) + } + // code == 0, extract the inner data if present + if innerData, hasInner := m["data"]; hasInner { + // Inner data might be a double-encoded JSON string + if s, ok := innerData.(string); ok && s != "" { + var parsed any + if err := json.Unmarshal([]byte(s), &parsed); err == nil { + runtime.Out(parsed, nil) + return nil + } + } + runtime.Out(innerData, nil) + return nil + } + runtime.Out(map[string]any{"success": true}, nil) + return nil + } + } + + runtime.Out(data, nil) + return nil +} diff --git a/shortcuts/base/base_role_create.go b/shortcuts/base/base_role_create.go new file mode 100644 index 00000000..18b4cb2d --- /dev/null +++ b/shortcuts/base/base_role_create.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRoleCreate = common.Shortcut{ + Service: "base", + Command: "+role-create", + Description: "Create a custom role in a Base", + Risk: "write", + Scopes: []string{"base:role:create"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "json", Desc: `body JSON (AdvPermBaseRoleConfig), e.g. {"role_name":"Reviewer","role_type":"custom_role","table_rule_map":{...}}`, Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + var body map[string]any + if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil { + return common.FlagErrorf("--json must be valid JSON: %v", err) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + var body map[string]any + json.Unmarshal([]byte(runtime.Str("json")), &body) + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/roles"). + Body(body). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + var body map[string]any + json.Unmarshal([]byte(runtime.Str("json")), &body) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/roles", validate.EncodePathSegment(baseToken)), + Body: body, + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "create role failed") + }, +} diff --git a/shortcuts/base/base_role_delete.go b/shortcuts/base/base_role_delete.go new file mode 100644 index 00000000..0c5627fc --- /dev/null +++ b/shortcuts/base/base_role_delete.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRoleDelete = common.Shortcut{ + Service: "base", + Command: "+role-delete", + Description: "Delete a custom role (system roles cannot be deleted)", + Risk: "high-risk-write", + Scopes: []string{"base:role:delete"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("role-id")) == "" { + return common.FlagErrorf("--role-id must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/roles/:role_id"). + Set("base_token", runtime.Str("base-token")). + Set("role_id", runtime.Str("role-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + roleId := runtime.Str("role-id") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodDelete, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/roles/%s", validate.EncodePathSegment(baseToken), validate.EncodePathSegment(roleId)), + Body: map[string]any{}, + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "delete role failed") + }, +} diff --git a/shortcuts/base/base_role_get.go b/shortcuts/base/base_role_get.go new file mode 100644 index 00000000..ada1c994 --- /dev/null +++ b/shortcuts/base/base_role_get.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRoleGet = common.Shortcut{ + Service: "base", + Command: "+role-get", + Description: "Get full config of a role", + Risk: "read", + Scopes: []string{"base:role:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("role-id")) == "" { + return common.FlagErrorf("--role-id must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/roles/:role_id"). + Set("base_token", runtime.Str("base-token")). + Set("role_id", runtime.Str("role-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + roleId := runtime.Str("role-id") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/roles/%s", validate.EncodePathSegment(baseToken), validate.EncodePathSegment(roleId)), + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "get role failed") + }, +} diff --git a/shortcuts/base/base_role_list.go b/shortcuts/base/base_role_list.go new file mode 100644 index 00000000..08bc2934 --- /dev/null +++ b/shortcuts/base/base_role_list.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRoleList = common.Shortcut{ + Service: "base", + Command: "+role-list", + Description: "List all roles in a Base", + Risk: "read", + Scopes: []string{"base:role:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/roles"). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/roles", validate.EncodePathSegment(baseToken)), + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "list roles failed") + }, +} diff --git a/shortcuts/base/base_role_test.go b/shortcuts/base/base_role_test.go new file mode 100644 index 00000000..d71e4c5d --- /dev/null +++ b/shortcuts/base/base_role_test.go @@ -0,0 +1,608 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// --------------------------------------------------------------------------- +// Validate tests +// --------------------------------------------------------------------------- + +func TestBaseRoleCreateValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "", "json": `{"role_name":"R"}`}, nil, nil) + if err := BaseRoleCreate.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("whitespace base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": " ", "json": `{"role_name":"R"}`}, nil, nil) + if err := BaseRoleCreate.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("invalid json", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "json": "{"}, nil, nil) + if err := BaseRoleCreate.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--json must be valid JSON") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "json": `{"role_name":"Reviewer","role_type":"custom_role"}`}, nil, nil) + if err := BaseRoleCreate.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseRoleDeleteValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "", "role-id": "rol_1"}, nil, nil) + if err := BaseRoleDelete.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("blank role-id", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": ""}, nil, nil) + if err := BaseRoleDelete.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--role-id must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("whitespace role-id", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": " "}, nil, nil) + if err := BaseRoleDelete.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--role-id must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1"}, nil, nil) + if err := BaseRoleDelete.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseRoleGetValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "", "role-id": "rol_1"}, nil, nil) + if err := BaseRoleGet.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("blank role-id", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": ""}, nil, nil) + if err := BaseRoleGet.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--role-id must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1"}, nil, nil) + if err := BaseRoleGet.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseRoleListValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": ""}, nil, nil) + if err := BaseRoleList.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + if err := BaseRoleList.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseRoleUpdateValidate(t *testing.T) { + ctx := context.Background() + + t.Run("blank base-token", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "", "role-id": "rol_1", "json": `{"role_name":"X"}`}, nil, nil) + if err := BaseRoleUpdate.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--base-token must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("blank role-id", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "", "json": `{"role_name":"X"}`}, nil, nil) + if err := BaseRoleUpdate.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--role-id must not be blank") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("invalid json", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1", "json": "["}, nil, nil) + if err := BaseRoleUpdate.Validate(ctx, rt); err == nil || !strings.Contains(err.Error(), "--json must be valid JSON") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("valid", func(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1", "json": `{"role_name":"New Name"}`}, nil, nil) + if err := BaseRoleUpdate.Validate(ctx, rt); err != nil { + t.Fatalf("err=%v", err) + } + }) +} + +// --------------------------------------------------------------------------- +// DryRun tests +// --------------------------------------------------------------------------- + +func TestBaseRoleCreateDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "json": `{"role_name":"Reviewer"}`}, nil, nil) + dr := BaseRoleCreate.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +func TestBaseRoleDeleteDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1"}, nil, nil) + dr := BaseRoleDelete.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +func TestBaseRoleGetDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1"}, nil, nil) + dr := BaseRoleGet.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +func TestBaseRoleListDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + dr := BaseRoleList.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +func TestBaseRoleUpdateDryRun(t *testing.T) { + rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "role-id": "rol_1", "json": `{"role_name":"New"}`}, nil, nil) + dr := BaseRoleUpdate.DryRun(context.Background(), rt) + if dr == nil { + t.Fatal("DryRun returned nil") + } +} + +// --------------------------------------------------------------------------- +// Shortcut metadata tests +// --------------------------------------------------------------------------- + +func TestBaseRoleShortcutMetadata(t *testing.T) { + tests := []struct { + name string + s common.Shortcut + command string + risk string + scopes []string + }{ + {"create", BaseRoleCreate, "+role-create", "write", []string{"base:role:create"}}, + {"delete", BaseRoleDelete, "+role-delete", "high-risk-write", []string{"base:role:delete"}}, + {"get", BaseRoleGet, "+role-get", "read", []string{"base:role:read"}}, + {"list", BaseRoleList, "+role-list", "read", []string{"base:role:read"}}, + {"update", BaseRoleUpdate, "+role-update", "high-risk-write", []string{"base:role:update"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.s.Command != tt.command { + t.Fatalf("command=%q want=%q", tt.s.Command, tt.command) + } + if tt.s.Risk != tt.risk { + t.Fatalf("risk=%q want=%q", tt.s.Risk, tt.risk) + } + if tt.s.Service != "base" { + t.Fatalf("service=%q", tt.s.Service) + } + if len(tt.s.Scopes) != len(tt.scopes) || tt.s.Scopes[0] != tt.scopes[0] { + t.Fatalf("scopes=%v want=%v", tt.s.Scopes, tt.scopes) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Execute tests (with httpmock) +// --------------------------------------------------------------------------- + +func TestBaseRoleCreateExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"role_id": "rol_new", "role_name": "Reviewer"}, + }, + }, + }) + args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Reviewer","role_type":"custom_role"}`} + if err := runShortcut(t, BaseRoleCreate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "rol_new") || !strings.Contains(got, "Reviewer") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseRoleDeleteExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/roles/rol_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": nil, + }, + }) + args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"} + if err := runShortcut(t, BaseRoleDelete, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "success") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseRoleGetExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/roles/rol_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "role_id": "rol_1", + "role_name": "Admin", + "role_type": "system_role", + }, + }, + }, + }) + args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_1"} + if err := runShortcut(t, BaseRoleGet, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "rol_1") || !strings.Contains(got, "Admin") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseRoleListExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/roles", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "code": 0, + "data": []interface{}{ + map[string]interface{}{"role_id": "rol_1", "role_name": "Admin"}, + map[string]interface{}{"role_id": "rol_2", "role_name": "Viewer"}, + }, + }, + }, + }) + args := []string{"+role-list", "--base-token", "app_x"} + if err := runShortcut(t, BaseRoleList, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "rol_1") || !strings.Contains(got, "rol_2") { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseRoleUpdateExecute(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/roles/rol_1", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"role_id": "rol_1", "role_name": "Editor"}, + }, + }, + }) + args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"Editor"}`, "--yes"} + if err := runShortcut(t, BaseRoleUpdate, args, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "rol_1") || !strings.Contains(got, "Editor") { + t.Fatalf("stdout=%s", got) + } +} + +// --------------------------------------------------------------------------- +// Execute error paths +// --------------------------------------------------------------------------- + +func TestBaseRoleCreateExecuteAPIError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/roles", + Body: map[string]interface{}{ + "code": 190001, + "msg": "bad request", + }, + }) + args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Bad"}`} + if err := runShortcut(t, BaseRoleCreate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseRoleListExecuteTransportError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/roles", + Status: 500, + Body: "internal server error", + }) + args := []string{"+role-list", "--base-token", "app_x"} + if err := runShortcut(t, BaseRoleList, args, factory, stdout); err == nil { + t.Fatalf("expected transport error") + } +} + +func TestBaseRoleListExecuteAPIError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/roles", + Body: map[string]interface{}{ + "code": 190002, + "msg": "not found", + }, + }) + args := []string{"+role-list", "--base-token", "app_x"} + if err := runShortcut(t, BaseRoleList, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseRoleDeleteExecuteAPIError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/roles/rol_1", + Body: map[string]interface{}{ + "code": 190003, + "msg": "forbidden", + }, + }) + args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"} + if err := runShortcut(t, BaseRoleDelete, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190003") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseRoleUpdateExecuteAPIError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/base/v3/bases/app_x/roles/rol_1", + Body: map[string]interface{}{ + "code": 190004, + "msg": "invalid params", + }, + }) + args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"X"}`, "--yes"} + if err := runShortcut(t, BaseRoleUpdate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190004") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseRoleGetExecuteBusinessError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/roles/rol_bad", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "code": 100001, + "message": "role not found", + }, + }, + }) + args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_bad"} + if err := runShortcut(t, BaseRoleGet, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "100001") || !strings.Contains(err.Error(), "role not found") { + t.Fatalf("err=%v", err) + } +} + +// --------------------------------------------------------------------------- +// handleRoleResponse unit tests +// --------------------------------------------------------------------------- + +func newRoleResponseRuntime(t *testing.T) *common.RuntimeContext { + t.Helper() + factory, _, _ := newExecuteFactory(t) + cfg, _ := factory.Config() + return &common.RuntimeContext{ + Cmd: &cobra.Command{Use: "test"}, + Config: cfg, + Factory: factory, + } +} + +func TestHandleRoleResponse(t *testing.T) { + t.Run("invalid json", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + if err := handleRoleResponse(rt, []byte("{bad"), "test"); err == nil || !strings.Contains(err.Error(), "failed to parse response") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("outer error code", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + if err := handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"); err == nil || !strings.Contains(err.Error(), "999") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("outer error code with empty msg and data.error.message", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":1,"data":{"error":{"hint":"failed to update","message":"the name already exists!","type":""}},"msg":""}` + err := handleRoleResponse(rt, []byte(body), "test") + if err == nil || !strings.Contains(err.Error(), "the name already exists!") { + t.Fatalf("err=%v, want error containing 'the name already exists!'", err) + } + }) + + t.Run("outer error code with empty msg and no data error", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":2,"data":{},"msg":""}` + err := handleRoleResponse(rt, []byte(body), "test") + if err == nil || !strings.Contains(err.Error(), "[2]") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("null data", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + if err := handleRoleResponse(rt, []byte(`{"code":0,"msg":"ok","data":null}`), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("empty string data", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + if err := handleRoleResponse(rt, []byte(`{"code":0,"msg":"ok","data":""}`), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("empty data field", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + if err := handleRoleResponse(rt, []byte(`{"code":0,"msg":"ok"}`), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("double encoded json string", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":"{\"role_id\":\"rol_1\"}"}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("non-parseable string data", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":"just a plain string"}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("business code zero with inner data", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":{"code":0,"data":{"role_id":"rol_1"}}}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("business code zero with double-encoded inner data", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":{"code":0,"data":"{\"role_id\":\"rol_1\"}"}}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("business code zero without inner data", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":{"code":0,"message":"ok"}}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("business code non-zero", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":{"code":50001,"message":"permission denied"}}` + if err := handleRoleResponse(rt, []byte(body), "test"); err == nil || !strings.Contains(err.Error(), "50001") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("data is array", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":[{"role_id":"rol_1"},{"role_id":"rol_2"}]}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) + + t.Run("data is object without code field", func(t *testing.T) { + rt := newRoleResponseRuntime(t) + body := `{"code":0,"msg":"ok","data":{"role_id":"rol_1","role_name":"Admin"}}` + if err := handleRoleResponse(rt, []byte(body), "test"); err != nil { + t.Fatalf("err=%v", err) + } + }) +} diff --git a/shortcuts/base/base_role_update.go b/shortcuts/base/base_role_update.go new file mode 100644 index 00000000..8c05c4d8 --- /dev/null +++ b/shortcuts/base/base_role_update.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRoleUpdate = common.Shortcut{ + Service: "base", + Command: "+role-update", + Description: "Update a role config (delta merge, only changed fields needed)", + Risk: "high-risk-write", + Scopes: []string{"base:role:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "role-id", Desc: "role ID (e.g. rolxxxxxx4)", Required: true}, + {Name: "json", Desc: `body JSON (delta AdvPermBaseRoleConfig), e.g. {"role_name":"New Name","role_type":"custom_role","table_rule_map":{...}}`, Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("role-id")) == "" { + return common.FlagErrorf("--role-id must not be blank") + } + var body map[string]any + if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil { + return common.FlagErrorf("--json must be valid JSON: %v", err) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + var body map[string]any + json.Unmarshal([]byte(runtime.Str("json")), &body) + return common.NewDryRunAPI(). + Desc("Delta merge: only changed fields are updated, others remain unchanged"). + PUT("/open-apis/base/v3/bases/:base_token/roles/:role_id"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("role_id", runtime.Str("role-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + roleId := runtime.Str("role-id") + var body map[string]any + json.Unmarshal([]byte(runtime.Str("json")), &body) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPut, + ApiPath: fmt.Sprintf("/open-apis/base/v3/bases/%s/roles/%s", validate.EncodePathSegment(baseToken), validate.EncodePathSegment(roleId)), + Body: body, + }) + if err != nil { + return err + } + + return handleRoleResponse(runtime, apiResp.RawBody, "update role failed") + }, +} diff --git a/shortcuts/base/base_shortcut_helpers.go b/shortcuts/base/base_shortcut_helpers.go new file mode 100644 index 00000000..86e30713 --- /dev/null +++ b/shortcuts/base/base_shortcut_helpers.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +func baseTableID(runtime *common.RuntimeContext) string { + return strings.TrimSpace(runtime.Str("table-id")) +} + +func loadJSONInput(raw string, flagName string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", common.FlagErrorf("--%s cannot be empty", flagName) + } + if !strings.HasPrefix(raw, "@") { + return raw, nil + } + path := strings.TrimSpace(strings.TrimPrefix(raw, "@")) + if path == "" { + return "", common.FlagErrorf("--%s file path cannot be empty after @", flagName) + } + safePath, err := validate.SafeInputPath(path) + if err != nil { + return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, err) + } + data, err := os.ReadFile(safePath) + if err != nil { + return "", common.FlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err) + } + content := strings.TrimSpace(string(data)) + if content == "" { + return "", common.FlagErrorf("--%s JSON file %q is empty", flagName, path) + } + return content, nil +} + +func jsonInputTip(flagName string) string { + return fmt.Sprintf("tip: pass a JSON object/array directly, or use --%s @path/to/file.json", flagName) +} + +func formatJSONError(flagName string, target string, err error) error { + if syntaxErr, ok := err.(*json.SyntaxError); ok { + return common.FlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName)) + } + if typeErr, ok := err.(*json.UnmarshalTypeError); ok { + if typeErr.Field != "" { + return common.FlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName)) + } + return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName)) + } + return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName)) +} + +func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags []string) (string, error) { + active := []string{} + for _, name := range boolFlags { + if runtime.Bool(name) { + active = append(active, name) + } + } + for _, name := range stringFlags { + if strings.TrimSpace(runtime.Str(name)) != "" { + active = append(active, name) + } + } + if len(active) == 0 { + return "", common.FlagErrorf("specify one action") + } + if len(active) > 1 { + flags := make([]string, 0, len(active)) + for _, item := range active { + flags = append(flags, "--"+item) + } + return "", common.FlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", ")) + } + return active[0], nil +} + +func parseObjectList(raw string, flagName string) ([]map[string]interface{}, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var err error + raw, err = loadJSONInput(raw, flagName) + if err != nil { + return nil, err + } + if strings.HasPrefix(raw, "[") { + arr, err := parseJSONArray(raw, flagName) + if err != nil { + return nil, err + } + items := make([]map[string]interface{}, 0, len(arr)) + for idx, item := range arr { + obj, ok := item.(map[string]interface{}) + if !ok { + return nil, common.FlagErrorf("--%s item %d must be an object", flagName, idx+1) + } + items = append(items, obj) + } + return items, nil + } + obj, err := parseJSONObject(raw, flagName) + if err != nil { + return nil, err + } + return []map[string]interface{}{obj}, nil +} + +func parseJSONValue(raw string, flagName string) (interface{}, error) { + var err error + raw, err = loadJSONInput(raw, flagName) + if err != nil { + return nil, err + } + var value interface{} + if err := common.ParseJSON([]byte(raw), &value); err != nil { + return nil, formatJSONError(flagName, "value", err) + } + switch value.(type) { + case map[string]interface{}, []interface{}: + return value, nil + default: + return nil, common.FlagErrorf("--%s must be a JSON object or array", flagName) + } +} diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go new file mode 100644 index 00000000..a6f1c61d --- /dev/null +++ b/shortcuts/base/base_shortcuts_test.go @@ -0,0 +1,260 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts/common" +) + +func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext { + cmd := &cobra.Command{Use: "test"} + for name := range stringFlags { + cmd.Flags().String(name, "", "") + } + for name := range boolFlags { + cmd.Flags().Bool(name, false, "") + } + for name := range intFlags { + cmd.Flags().Int(name, 0, "") + } + _ = cmd.ParseFlags(nil) + for name, value := range stringFlags { + _ = cmd.Flags().Set(name, value) + } + for name, value := range boolFlags { + if value { + _ = cmd.Flags().Set(name, "true") + } + } + for name, value := range intFlags { + _ = cmd.Flags().Set(name, strconv.Itoa(value)) + } + return &common.RuntimeContext{Cmd: cmd, Config: &core.CliConfig{UserOpenId: "ou_test"}} +} + +func TestBaseAction(t *testing.T) { + t.Run("missing action", func(t *testing.T) { + runtime := newBaseTestRuntime(map[string]string{"get": ""}, map[string]bool{"list": false}, nil) + _, err := baseAction(runtime, []string{"list"}, []string{"get"}) + if err == nil || !strings.Contains(err.Error(), "specify one action") { + t.Fatalf("err=%v", err) + } + }) + + t.Run("single bool action", func(t *testing.T) { + runtime := newBaseTestRuntime(map[string]string{"get": ""}, map[string]bool{"list": true}, nil) + action, err := baseAction(runtime, []string{"list"}, []string{"get"}) + if err != nil || action != "list" { + t.Fatalf("action=%q err=%v", action, err) + } + }) + + t.Run("mutually exclusive", func(t *testing.T) { + runtime := newBaseTestRuntime(map[string]string{"get": "tbl_1"}, map[string]bool{"list": true}, nil) + _, err := baseAction(runtime, []string{"list"}, []string{"get"}) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("err=%v", err) + } + }) +} + +func TestParseObjectList(t *testing.T) { + items, err := parseObjectList("", "view") + if err != nil || items != nil { + t.Fatalf("items=%v err=%v", items, err) + } + + items, err = parseObjectList(`{"name":"grid"}`, "view") + if err != nil || len(items) != 1 || items[0]["name"] != "grid" { + t.Fatalf("items=%v err=%v", items, err) + } + + items, err = parseObjectList(`[{"name":"grid"}]`, "view") + if err != nil || len(items) != 1 || items[0]["name"] != "grid" { + t.Fatalf("items=%v err=%v", items, err) + } + + _, err = parseObjectList(`[1]`, "view") + if err == nil || !strings.Contains(err.Error(), "must be an object") { + t.Fatalf("err=%v", err) + } +} + +func TestWrapViewPropertyBody(t *testing.T) { + arr := []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}} + wrapped := wrapViewPropertyBody(arr, "group_config") + wrappedMap, ok := wrapped.(map[string]interface{}) + if !ok { + t.Fatalf("wrapped type=%T", wrapped) + } + if !reflect.DeepEqual(wrappedMap["group_config"], arr) { + t.Fatalf("wrapped group_config=%v want=%v", wrappedMap["group_config"], arr) + } + + obj := map[string]interface{}{"group_config": arr} + if got := wrapViewPropertyBody(obj, "group_config"); !reflect.DeepEqual(got, obj) { + t.Fatalf("got=%v want=%v", got, obj) + } +} + +func TestShortcutsCatalog(t *testing.T) { + shortcuts := Shortcuts() + want := []string{ + "+table-list", "+table-get", "+table-create", "+table-update", "+table-delete", + "+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options", + "+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename", + "+record-list", "+record-get", "+record-upsert", "+record-upload-attachment", "+record-delete", + "+record-history-list", + "+base-get", "+base-copy", "+base-create", + "+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable", + "+workflow-list", "+workflow-get", "+workflow-create", "+workflow-update", "+workflow-enable", "+workflow-disable", + "+data-query", + "+form-create", "+form-delete", "+form-list", "+form-update", "+form-get", + "+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list", + "+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", + "+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete", + } + if len(shortcuts) != len(want) { + t.Fatalf("len(shortcuts)=%d want=%d", len(shortcuts), len(want)) + } + for index, command := range want { + if shortcuts[index].Command != command { + t.Fatalf("command[%d]=%q want=%q", index, shortcuts[index].Command, command) + } + } +} + +func TestShortcutsDryRunCoverage(t *testing.T) { + for _, shortcut := range Shortcuts() { + if shortcut.DryRun == nil { + t.Fatalf("shortcut %q missing DryRun", shortcut.Command) + } + } +} + +func TestBaseTableDeleteRisk(t *testing.T) { + if BaseTableDelete.Risk != "high-risk-write" { + t.Fatalf("risk=%q want=%q", BaseTableDelete.Risk, "high-risk-write") + } +} + +func TestBaseDeleteShortcutsRisk(t *testing.T) { + cases := map[string]string{ + BaseFieldDelete.Command: BaseFieldDelete.Risk, + BaseViewDelete.Command: BaseViewDelete.Risk, + BaseRecordDelete.Command: BaseRecordDelete.Risk, + BaseFormDelete.Command: BaseFormDelete.Risk, + BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk, + BaseDashboardDelete.Command: BaseDashboardDelete.Risk, + BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk, + BaseRoleDelete.Command: BaseRoleDelete.Risk, + } + + for command, risk := range cases { + if risk != "high-risk-write" { + t.Fatalf("command=%q risk=%q want=%q", command, risk, "high-risk-write") + } + } +} + +func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) { + parent := &cobra.Command{Use: "base"} + BaseFieldCreate.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + if cmd.Flags().Lookup("i-have-read-guide") == nil { + t.Fatalf("flag i-have-read-guide must exist for runtime validation") + } + if strings.Contains(cmd.Flags().FlagUsages(), "--i-have-read-guide") { + t.Fatalf("help should not include --i-have-read-guide") + } +} + +func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) { + parent := &cobra.Command{Use: "base"} + BaseFieldUpdate.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + if cmd.Flags().Lookup("i-have-read-guide") == nil { + t.Fatalf("flag i-have-read-guide must exist for runtime validation") + } + if strings.Contains(cmd.Flags().FlagUsages(), "--i-have-read-guide") { + t.Fatalf("help should not include --i-have-read-guide") + } +} + +func TestBaseFieldValidate(t *testing.T) { + ctx := context.Background() + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err != nil { + t.Fatalf("invalid json should bypass CLI validate, err=%v", err) + } + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") { + t.Fatalf("err=%v", err) + } + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"lookup"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") { + t.Fatalf("err=%v", err) + } + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil { + t.Fatalf("formula create validate err=%v", err) + } + if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"Amount"}`}, nil, nil)); err != nil { + t.Fatalf("update validate err=%v", err) + } + if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") { + t.Fatalf("err=%v", err) + } + if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"lookup"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") { + t.Fatalf("err=%v", err) + } + if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil { + t.Fatalf("formula update validate err=%v", err) + } +} + +func TestBaseTableValidate(t *testing.T) { + ctx := context.Background() + if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": "{"}, nil, nil)); err != nil { + t.Fatalf("invalid fields json should bypass CLI validate, err=%v", err) + } + if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "view": `[1]`}, nil, nil)); err != nil { + t.Fatalf("invalid view json should bypass CLI validate, err=%v", err) + } + if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": `[{"name":"Name","type":"text"}]`, "view": `{"name":"Main"}`}, nil, nil)); err != nil { + t.Fatalf("create validate err=%v", err) + } +} + +func TestBaseRecordValidate(t *testing.T) { + ctx := context.Background() + if BaseRecordList.Validate != nil { + t.Fatalf("record list validate should be nil after removing --fields") + } + if BaseRecordGet.Validate != nil { + t.Fatalf("record get validate should be nil after removing --fields") + } + if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"A"}`}, nil, nil)); err != nil { + t.Fatalf("upsert validate err=%v", err) + } + if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": "{"}, nil, nil)); err != nil { + t.Fatalf("invalid record json should bypass CLI validate, err=%v", err) + } +} + +func TestBaseViewValidate(t *testing.T) { + ctx := context.Background() + if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil { + t.Fatalf("create validate err=%v", err) + } + if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err != nil { + t.Fatalf("invalid view json should bypass CLI validate, err=%v", err) + } +} diff --git a/shortcuts/base/dashboard_block_create.go b/shortcuts/base/dashboard_block_create.go new file mode 100644 index 00000000..7d16d06b --- /dev/null +++ b/shortcuts/base/dashboard_block_create.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardBlockCreate = common.Shortcut{ + Service: "base", + Command: "+dashboard-block-create", + Description: "Create a block in a dashboard", + Risk: "write", + Scopes: []string{"base:dashboard:create"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + {Name: "name", Desc: "block name", Required: true}, + {Name: "type", Desc: "block type: column / bar / line / pie / ring / area / combo / scatter / funnel / wordCloud / radar / statistics", Required: true}, + {Name: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"}, + {Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, + {Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Bool("no-validate") { + return nil + } + raw := runtime.Str("data-config") + if strings.TrimSpace(raw) == "" { + return nil // 允许无 data_config 的创建(某些类型可先创建后配置) + } + cfg, err := parseJSONObject(raw, "data-config") + if err != nil { + return err + } + norm := normalizeDataConfig(cfg) + if errs := validateBlockDataConfig(runtime.Str("type"), norm); len(errs) > 0 { + return formatDataConfigErrors(errs) + } + // 用规范化后的 JSON 覆写 flag,确保后续透传一致 + b, _ := json.Marshal(norm) + _ = runtime.Cmd.Flags().Set("data-config", string(b)) + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := runtime.Str("name"); name != "" { + body["name"] = name + } + if t := runtime.Str("type"); t != "" { + body["type"] = t + } + if raw := runtime.Str("data-config"); raw != "" { + if parsed, err := parseJSONObject(raw, "data-config"); err == nil { + body["data_config"] = parsed + } + } + params := map[string]interface{}{} + if uid := runtime.Str("user-id-type"); uid != "" { + params["user_id_type"] = uid + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks"). + Params(params). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardBlockCreate(runtime) + }, +} diff --git a/shortcuts/base/dashboard_block_delete.go b/shortcuts/base/dashboard_block_delete.go new file mode 100644 index 00000000..97d667a9 --- /dev/null +++ b/shortcuts/base/dashboard_block_delete.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardBlockDelete = common.Shortcut{ + Service: "base", + Command: "+dashboard-block-delete", + Description: "Delete a dashboard block", + Risk: "high-risk-write", + Scopes: []string{"base:dashboard:delete"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + blockIDFlag(true), + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")). + Set("block_id", runtime.Str("block-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardBlockDelete(runtime) + }, +} diff --git a/shortcuts/base/dashboard_block_get.go b/shortcuts/base/dashboard_block_get.go new file mode 100644 index 00000000..b6c605e3 --- /dev/null +++ b/shortcuts/base/dashboard_block_get.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardBlockGet = common.Shortcut{ + Service: "base", + Command: "+dashboard-block-get", + Description: "Get a dashboard block by ID", + Risk: "read", + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + blockIDFlag(true), + {Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{} + if uid := strings.TrimSpace(runtime.Str("user-id-type")); uid != "" { + params["user_id_type"] = uid + } + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). + Params(params). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")). + Set("block_id", runtime.Str("block-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardBlockGet(runtime) + }, +} diff --git a/shortcuts/base/dashboard_block_list.go b/shortcuts/base/dashboard_block_list.go new file mode 100644 index 00000000..852dcc18 --- /dev/null +++ b/shortcuts/base/dashboard_block_list.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardBlockList = common.Shortcut{ + Service: "base", + Command: "+dashboard-block-list", + Description: "List blocks in a dashboard", + Risk: "read", + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + {Name: "page-size", Desc: "page size (max 100)"}, + {Name: "page-token", Desc: "pagination token"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{} + if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" { + params["page_size"] = ps + } + if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" { + params["page_token"] = pt + } + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks"). + Params(params). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardBlockList(runtime) + }, +} diff --git a/shortcuts/base/dashboard_block_update.go b/shortcuts/base/dashboard_block_update.go new file mode 100644 index 00000000..a194b03a --- /dev/null +++ b/shortcuts/base/dashboard_block_update.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardBlockUpdate = common.Shortcut{ + Service: "base", + Command: "+dashboard-block-update", + Description: "Update a dashboard block", + Risk: "write", + Scopes: []string{"base:dashboard:update"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + blockIDFlag(true), + {Name: "name", Desc: "new block name"}, + {Name: "data-config", Desc: "data config JSON object (table_name, series, count_all, group_by, filter, etc.)"}, + {Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"}, + {Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Bool("no-validate") { + return nil + } + raw := runtime.Str("data-config") + if strings.TrimSpace(raw) == "" { + return nil + } + cfg, err := parseJSONObject(raw, "data-config") + if err != nil { + return err + } + norm := normalizeDataConfig(cfg) + if errs := validateBlockDataConfig("", norm); len(errs) > 0 { // update 时不强校验类型特性 + return formatDataConfigErrors(errs) + } + b, _ := json.Marshal(norm) + _ = runtime.Cmd.Flags().Set("data-config", string(b)) + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := runtime.Str("name"); name != "" { + body["name"] = name + } + if raw := runtime.Str("data-config"); raw != "" { + if parsed, err := parseJSONObject(raw, "data-config"); err == nil { + body["data_config"] = parsed + } + } + params := map[string]interface{}{} + if uid := runtime.Str("user-id-type"); uid != "" { + params["user_id_type"] = uid + } + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). + Params(params). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")). + Set("block_id", runtime.Str("block-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardBlockUpdate(runtime) + }, +} diff --git a/shortcuts/base/dashboard_create.go b/shortcuts/base/dashboard_create.go new file mode 100644 index 00000000..214d8d17 --- /dev/null +++ b/shortcuts/base/dashboard_create.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardCreate = common.Shortcut{ + Service: "base", + Command: "+dashboard-create", + Description: "Create a dashboard in a base", + Risk: "write", + Scopes: []string{"base:dashboard:create"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "name", Desc: "dashboard name", Required: true}, + {Name: "theme-style", Desc: "theme style"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := runtime.Str("name"); name != "" { + body["name"] = name + } + if themeStyle := runtime.Str("theme-style"); themeStyle != "" { + body["theme"] = map[string]interface{}{"theme_style": themeStyle} + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/dashboards"). + Body(body). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardCreate(runtime) + }, +} diff --git a/shortcuts/base/dashboard_delete.go b/shortcuts/base/dashboard_delete.go new file mode 100644 index 00000000..5d6df006 --- /dev/null +++ b/shortcuts/base/dashboard_delete.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardDelete = common.Shortcut{ + Service: "base", + Command: "+dashboard-delete", + Description: "Delete a dashboard", + Risk: "high-risk-write", + Scopes: []string{"base:dashboard:delete"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardDelete(runtime) + }, +} diff --git a/shortcuts/base/dashboard_get.go b/shortcuts/base/dashboard_get.go new file mode 100644 index 00000000..90f21f6c --- /dev/null +++ b/shortcuts/base/dashboard_get.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardGet = common.Shortcut{ + Service: "base", + Command: "+dashboard-get", + Description: "Get a dashboard by ID", + Risk: "read", + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardGet(runtime) + }, +} diff --git a/shortcuts/base/dashboard_list.go b/shortcuts/base/dashboard_list.go new file mode 100644 index 00000000..8d270daa --- /dev/null +++ b/shortcuts/base/dashboard_list.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardList = common.Shortcut{ + Service: "base", + Command: "+dashboard-list", + Description: "List dashboards in a base", + Risk: "read", + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "page-size", Desc: "page size (max 100)"}, + {Name: "page-token", Desc: "pagination token"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{} + if ps := strings.TrimSpace(runtime.Str("page-size")); ps != "" { + params["page_size"] = ps + } + if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" { + params["page_token"] = pt + } + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/dashboards"). + Params(params). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardList(runtime) + }, +} diff --git a/shortcuts/base/dashboard_ops.go b/shortcuts/base/dashboard_ops.go new file mode 100644 index 00000000..c319fde5 --- /dev/null +++ b/shortcuts/base/dashboard_ops.go @@ -0,0 +1,303 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +func dashboardIDFlag(required bool) common.Flag { + return common.Flag{Name: "dashboard-id", Desc: "dashboard ID", Required: required} +} + +func blockIDFlag(required bool) common.Flag { + return common.Flag{Name: "block-id", Desc: "dashboard block ID", Required: required} +} + +func dryRunDashboardBase(runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")). + Set("block_id", runtime.Str("block-id")) +} + +func dryRunDashboardList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{} + if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" { + params["page_size"] = pageSize + } + if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { + params["page_token"] = pageToken + } + return dryRunDashboardBase(runtime). + GET("/open-apis/base/v3/bases/:base_token/dashboards"). + Params(params) +} + +func dryRunDashboardGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunDashboardBase(runtime). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id") +} + +func dryRunDashboardCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{"name": runtime.Str("name")} + if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" { + body["theme"] = map[string]interface{}{"theme_style": themeStyle} + } + return dryRunDashboardBase(runtime). + POST("/open-apis/base/v3/bases/:base_token/dashboards"). + Body(body) +} + +func dryRunDashboardUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" { + body["theme"] = map[string]interface{}{"theme_style": themeStyle} + } + return dryRunDashboardBase(runtime). + PATCH("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). + Body(body) +} + +func dryRunDashboardDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunDashboardBase(runtime). + DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id") +} + +func dryRunDashboardBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{} + if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" { + params["page_size"] = pageSize + } + if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { + params["page_token"] = pageToken + } + return dryRunDashboardBase(runtime). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks"). + Params(params) +} + +func dryRunDashboardBlockGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + return dryRunDashboardBase(runtime). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). + Params(params) +} + +func dryRunDashboardBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if blockType := strings.TrimSpace(runtime.Str("type")); blockType != "" { + body["type"] = blockType + } + if raw := runtime.Str("data-config"); raw != "" { + if parsed, err := parseJSONObject(raw, "data-config"); err == nil { + body["data_config"] = parsed + } + } + + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + return dryRunDashboardBase(runtime). + POST("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks"). + Params(params). + Body(body) +} + +func dryRunDashboardBlockUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if raw := runtime.Str("data-config"); raw != "" { + if parsed, err := parseJSONObject(raw, "data-config"); err == nil { + body["data_config"] = parsed + } + } + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + return dryRunDashboardBase(runtime). + PATCH("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id"). + Params(params). + Body(body) +} + +func dryRunDashboardBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunDashboardBase(runtime). + DELETE("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/blocks/:block_id") +} + +// ── Dashboard CRUD ────────────────────────────────────────────────── + +func executeDashboardList(runtime *common.RuntimeContext) error { + params := map[string]interface{}{} + if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" { + params["page_size"] = pageSize + } + if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { + params["page_token"] = pageToken + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards"), params, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil +} + +func executeDashboardGet(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"dashboard": data}, nil) + return nil +} + +func executeDashboardCreate(runtime *common.RuntimeContext) error { + body := map[string]interface{}{"name": runtime.Str("name")} + if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" { + body["theme"] = map[string]interface{}{"theme_style": themeStyle} + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "dashboards"), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"dashboard": data, "created": true}, nil) + return nil +} + +func executeDashboardUpdate(runtime *common.RuntimeContext) error { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if themeStyle := strings.TrimSpace(runtime.Str("theme-style")); themeStyle != "" { + body["theme"] = map[string]interface{}{"theme_style": themeStyle} + } + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"dashboard": data, "updated": true}, nil) + return nil +} + +func executeDashboardDelete(runtime *common.RuntimeContext) error { + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"deleted": true, "dashboard_id": runtime.Str("dashboard-id")}, nil) + return nil +} + +// ── Dashboard Block CRUD ──────────────────────────────────────────── + +func executeDashboardBlockList(runtime *common.RuntimeContext) error { + params := map[string]interface{}{} + if pageSize := strings.TrimSpace(runtime.Str("page-size")); pageSize != "" { + params["page_size"] = pageSize + } + if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" { + params["page_token"] = pageToken + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks"), params, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil +} + +func executeDashboardBlockGet(runtime *common.RuntimeContext) error { + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), params, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"block": data}, nil) + return nil +} + +func executeDashboardBlockCreate(runtime *common.RuntimeContext) error { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if blockType := strings.TrimSpace(runtime.Str("type")); blockType != "" { + body["type"] = blockType + } + if raw := runtime.Str("data-config"); raw != "" { + parsed, err := parseJSONObject(raw, "data-config") + if err != nil { + return err + } + body["data_config"] = parsed + } + + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks"), params, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"block": data, "created": true}, nil) + return nil +} + +func executeDashboardBlockUpdate(runtime *common.RuntimeContext) error { + body := map[string]interface{}{} + if name := strings.TrimSpace(runtime.Str("name")); name != "" { + body["name"] = name + } + if raw := runtime.Str("data-config"); raw != "" { + parsed, err := parseJSONObject(raw, "data-config") + if err != nil { + return err + } + body["data_config"] = parsed + } + params := map[string]interface{}{} + if userIDType := strings.TrimSpace(runtime.Str("user-id-type")); userIDType != "" { + params["user_id_type"] = userIDType + } + + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), params, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"block": data, "updated": true}, nil) + return nil +} + +func executeDashboardBlockDelete(runtime *common.RuntimeContext) error { + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "blocks", runtime.Str("block-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"deleted": true, "block_id": runtime.Str("block-id")}, nil) + return nil +} diff --git a/shortcuts/base/dashboard_update.go b/shortcuts/base/dashboard_update.go new file mode 100644 index 00000000..5832e902 --- /dev/null +++ b/shortcuts/base/dashboard_update.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseDashboardUpdate = common.Shortcut{ + Service: "base", + Command: "+dashboard-update", + Description: "Update a dashboard", + Risk: "write", + Scopes: []string{"base:dashboard:update"}, + AuthTypes: authTypes(), + HasFormat: true, + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + {Name: "name", Desc: "new dashboard name"}, + {Name: "theme-style", Desc: "theme style"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{} + if name := runtime.Str("name"); name != "" { + body["name"] = name + } + if themeStyle := runtime.Str("theme-style"); themeStyle != "" { + body["theme"] = map[string]interface{}{"theme_style": themeStyle} + } + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeDashboardUpdate(runtime) + }, +} diff --git a/shortcuts/base/field_create.go b/shortcuts/base/field_create.go new file mode 100644 index 00000000..715b4d7d --- /dev/null +++ b/shortcuts/base/field_create.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldCreate = common.Shortcut{ + Service: "base", + Command: "+field-create", + Description: "Create a field", + Risk: "write", + Scopes: []string{"base:field:create"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "json", Desc: "field property JSON object", Required: true}, + {Name: "i-have-read-guide", Type: "bool", Desc: "set only after you have read the formula/lookup guide for those field types", Hidden: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateFieldCreate(runtime) + }, + DryRun: dryRunFieldCreate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldCreate(runtime) + }, +} diff --git a/shortcuts/base/field_delete.go b/shortcuts/base/field_delete.go new file mode 100644 index 00000000..d242763d --- /dev/null +++ b/shortcuts/base/field_delete.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldDelete = common.Shortcut{ + Service: "base", + Command: "+field-delete", + Description: "Delete a field by ID or name", + Risk: "high-risk-write", + Scopes: []string{"base:field:delete"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)}, + DryRun: dryRunFieldDelete, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldDelete(runtime) + }, +} diff --git a/shortcuts/base/field_get.go b/shortcuts/base/field_get.go new file mode 100644 index 00000000..63d66a69 --- /dev/null +++ b/shortcuts/base/field_get.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldGet = common.Shortcut{ + Service: "base", + Command: "+field-get", + Description: "Get a field by ID or name", + Risk: "read", + Scopes: []string{"base:field:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), fieldRefFlag(true)}, + DryRun: dryRunFieldGet, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldGet(runtime) + }, +} diff --git a/shortcuts/base/field_list.go b/shortcuts/base/field_list.go new file mode 100644 index 00000000..da487827 --- /dev/null +++ b/shortcuts/base/field_list.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldList = common.Shortcut{ + Service: "base", + Command: "+field-list", + Description: "List fields in a table", + Risk: "read", + Scopes: []string{"base:field:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "100", Desc: "pagination size"}, + }, + DryRun: dryRunFieldList, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldList(runtime) + }, +} diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go new file mode 100644 index 00000000..0976c90d --- /dev/null +++ b/shortcuts/base/field_ops.go @@ -0,0 +1,222 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields"). + Params(map[string]interface{}{"offset": offset, "limit": limit}). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)) +} + +func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("field_id", runtime.Str("field-id")) +} + +func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, _ := parseJSONObject(runtime.Str("json"), "json") + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)) +} + +func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, _ := parseJSONObject(runtime.Str("json"), "json") + return common.NewDryRunAPI(). + PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("field_id", runtime.Str("field-id")) +} + +func dryRunFieldDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("field_id", runtime.Str("field-id")) +} + +func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{ + "offset": runtime.Int("offset"), + "limit": runtime.Int("limit"), + } + if params["limit"].(int) <= 0 { + params["limit"] = 30 + } + if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" { + params["query"] = keyword + } + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id/options"). + Params(params). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("field_id", runtime.Str("field-id")) +} + +func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) { + raw, _ := loadJSONInput(runtime.Str("json"), "json") + if raw == "" { + return nil, nil + } + var body map[string]interface{} + _ = common.ParseJSON([]byte(raw), &body) + if body == nil { + return nil, nil + } + return body, nil +} + +func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error { + fieldType := strings.ToLower(strings.TrimSpace(common.GetString(body, "type"))) + if (fieldType == "formula" || fieldType == "lookup") && !runtime.Bool("i-have-read-guide") { + guidePath := "skills/lark-base/references/formula-field-guide.md" + if fieldType == "lookup" { + guidePath = "skills/lark-base/references/lookup-field-guide.md" + } + return common.FlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath) + } + return nil +} + +func validateFieldCreate(runtime *common.RuntimeContext) error { + body, err := validateFieldJSON(runtime) + if err != nil { + return err + } + return validateFormulaLookupGuideAck(runtime, "+field-create", body) +} + +func validateFieldUpdate(runtime *common.RuntimeContext) error { + body, err := validateFieldJSON(runtime) + if err != nil { + return err + } + return validateFormulaLookupGuideAck(runtime, "+field-update", body) +} + +func executeFieldList(runtime *common.RuntimeContext) error { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + fields, total, err := listAllFields(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit) + if err != nil { + return err + } + if total == 0 { + total = len(fields) + } + runtime.Out(map[string]interface{}{"items": simplifyFields(fields), "offset": offset, "limit": limit, "count": len(fields), "total": total}, nil) + return nil +} + +func executeFieldGet(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + fieldRef := runtime.Str("field-id") + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"field": data}, nil) + return nil +} + +func executeFieldCreate(runtime *common.RuntimeContext) error { + body, err := parseJSONObject(runtime.Str("json"), "json") + if err != nil { + return err + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"field": data, "created": true}, nil) + return nil +} + +func executeFieldUpdate(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + body, err := parseJSONObject(runtime.Str("json"), "json") + if err != nil { + return err + } + fieldRef := runtime.Str("field-id") + data, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil) + return nil +} + +func executeFieldDelete(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + fieldRef := runtime.Str("field-id") + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"deleted": true, "field_id": fieldRef, "field_name": fieldRef}, nil) + return nil +} + +func executeFieldSearchOptions(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + fieldRef := runtime.Str("field-id") + params := map[string]interface{}{ + "offset": runtime.Int("offset"), + "limit": runtime.Int("limit"), + } + if params["limit"].(int) <= 0 { + params["limit"] = 30 + } + if keyword := strings.TrimSpace(runtime.Str("keyword")); keyword != "" { + params["query"] = keyword + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef, "options"), params, nil) + if err != nil { + return err + } + options, _ := data["options"].([]interface{}) + total := toInt(data["total"]) + if total == 0 { + total = len(options) + } + runtime.Out(map[string]interface{}{ + "field_id": fieldRef, + "field_name": fieldRef, + "keyword": strings.TrimSpace(runtime.Str("keyword")), + "options": options, + "total": total, + }, nil) + return nil +} diff --git a/shortcuts/base/field_search_options.go b/shortcuts/base/field_search_options.go new file mode 100644 index 00000000..674cfe6b --- /dev/null +++ b/shortcuts/base/field_search_options.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldSearchOptions = common.Shortcut{ + Service: "base", + Command: "+field-search-options", + Description: "Search select options of a field", + Risk: "read", + Scopes: []string{"base:field:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + fieldRefFlag(true), + {Name: "keyword", Desc: "keyword for option query"}, + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "30", Desc: "pagination size"}, + }, + DryRun: dryRunFieldSearchOptions, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldSearchOptions(runtime) + }, +} diff --git a/shortcuts/base/field_update.go b/shortcuts/base/field_update.go new file mode 100644 index 00000000..fd8d755d --- /dev/null +++ b/shortcuts/base/field_update.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseFieldUpdate = common.Shortcut{ + Service: "base", + Command: "+field-update", + Description: "Update a field by ID or name", + Risk: "write", + Scopes: []string{"base:field:update"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + fieldRefFlag(true), + {Name: "json", Desc: "field property JSON object", Required: true}, + {Name: "i-have-read-guide", Type: "bool", Desc: "acknowledge reading formula/lookup guide before creating or updating those field types", Hidden: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateFieldUpdate(runtime) + }, + DryRun: dryRunFieldUpdate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeFieldUpdate(runtime) + }, +} diff --git a/shortcuts/base/helpers.go b/shortcuts/base/helpers.go new file mode 100644 index 00000000..9032ab5a --- /dev/null +++ b/shortcuts/base/helpers.go @@ -0,0 +1,1129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + batchSize = 500 + baseV3ServicePath = "/open-apis/base/v3" +) + +type fieldTypeSpec struct { + Type string + Extra map[string]interface{} +} + +func parseJSONObject(raw string, flagName string) (map[string]interface{}, error) { + resolved, err := loadJSONInput(raw, flagName) + if err != nil { + return nil, err + } + var result map[string]interface{} + if err := common.ParseJSON([]byte(resolved), &result); err != nil { + return nil, formatJSONError(flagName, "object", err) + } + return result, nil +} + +func parseJSONArray(raw string, flagName string) ([]interface{}, error) { + resolved, err := loadJSONInput(raw, flagName) + if err != nil { + return nil, err + } + var result []interface{} + if err := common.ParseJSON([]byte(resolved), &result); err != nil { + return nil, formatJSONError(flagName, "array", err) + } + return result, nil +} + +func parseStringListFlexible(raw string, flagName string) ([]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + resolved, err := loadJSONInput(raw, flagName) + if err != nil { + return nil, err + } + if strings.HasPrefix(resolved, "[") { + var result []string + if err := common.ParseJSON([]byte(resolved), &result); err != nil { + return nil, formatJSONError(flagName, "string array", err) + } + return result, nil + } + raw = resolved + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + item := strings.TrimSpace(part) + if item != "" { + result = append(result, item) + } + } + return result, nil +} + +func parseStringList(raw string) []string { + items, _ := parseStringListFlexible(raw, "fields") + return items +} + +func deepMergeMaps(dst, src map[string]interface{}) map[string]interface{} { + if dst == nil { + dst = map[string]interface{}{} + } + for key, value := range src { + if srcMap, ok := value.(map[string]interface{}); ok { + if dstMap, ok := dst[key].(map[string]interface{}); ok { + dst[key] = deepMergeMaps(dstMap, srcMap) + } else { + dst[key] = deepMergeMaps(map[string]interface{}{}, srcMap) + } + continue + } + dst[key] = value + } + return dst +} + +func cloneMap(src map[string]interface{}) map[string]interface{} { + if src == nil { + return nil + } + dst := make(map[string]interface{}, len(src)) + for key, value := range src { + dst[key] = cloneValue(value) + } + return dst +} + +func cloneValue(value interface{}) interface{} { + switch val := value.(type) { + case map[string]interface{}: + return cloneMap(val) + case []interface{}: + cloned := make([]interface{}, len(val)) + for i, item := range val { + cloned[i] = cloneValue(item) + } + return cloned + default: + return val + } +} + +func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) { + trimmed := strings.TrimSpace(typeName) + if trimmed == "" { + return fieldTypeSpec{}, fmt.Errorf("field type cannot be empty") + } + switch strings.ToLower(trimmed) { + case "text", "phone", "url", "email", "barcode": + return fieldTypeSpec{Type: "text"}, nil + case "number": + return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "number", "formatter": "0"}}}, nil + case "currency": + return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "currency", "currency_code": "CNY", "formatter": "0.00"}}}, nil + case "progress": + return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "progress", "min": 0, "max": 100, "color": "Blue"}}}, nil + case "rating": + return fieldTypeSpec{Type: "number", Extra: map[string]interface{}{"style": map[string]interface{}{"type": "rating", "icon": "star", "min": 1, "max": 5}}}, nil + case "singleselect", "single_select", "single-select": + return fieldTypeSpec{Type: "select", Extra: map[string]interface{}{"multiple": false}}, nil + case "multiselect", "multi_select", "multi-select": + return fieldTypeSpec{Type: "select", Extra: map[string]interface{}{"multiple": true}}, nil + case "datetime", "date", "date_time", "date-time": + return fieldTypeSpec{Type: "datetime", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil + case "checkbox": + return fieldTypeSpec{Type: "checkbox"}, nil + case "user", "groupchat", "group_chat", "group-chat": + return fieldTypeSpec{Type: "user", Extra: map[string]interface{}{"multiple": true}}, nil + case "attachment": + return fieldTypeSpec{Type: "attachment"}, nil + case "link": + return fieldTypeSpec{Type: "link"}, nil + case "twowaylink", "two_way_link", "two-way-link": + return fieldTypeSpec{Type: "link", Extra: map[string]interface{}{"bidirectional": true}}, nil + case "formula": + return fieldTypeSpec{Type: "formula"}, nil + case "location": + return fieldTypeSpec{Type: "location"}, nil + case "autonumber", "auto_number", "auto-number": + return fieldTypeSpec{Type: "auto_number", Extra: map[string]interface{}{"style": map[string]interface{}{"rules": []interface{}{map[string]interface{}{"type": "text", "text": "NO."}, map[string]interface{}{"type": "incremental_number", "length": 3}}}}}, nil + case "createdtime", "created_time", "created-time": + return fieldTypeSpec{Type: "created_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil + case "modifiedtime", "modified_time", "modified-time": + return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil + default: + return fieldTypeSpec{}, fmt.Errorf("unsupported field type %q in base/v3", typeName) + } +} + +func normalizeFieldTypeName(typeName string) string { + return strings.TrimSpace(typeName) +} + +func normalizeViewTypeName(typeName string) string { + trimmed := strings.TrimSpace(typeName) + if trimmed == "" { + return trimmed + } + switch strings.ToLower(trimmed) { + case "grid": + return "grid" + case "kanban": + return "kanban" + case "gallery": + return "gallery" + case "gantt": + return "gantt" + case "calendar": + return "calendar" + default: + return trimmed + } +} + +func normalizeSelectOptions(raw interface{}) []interface{} { + src, ok := raw.([]interface{}) + if !ok { + return nil + } + result := make([]interface{}, 0, len(src)) + for _, item := range src { + switch v := item.(type) { + case string: + result = append(result, map[string]interface{}{"name": v}) + case map[string]interface{}: + option := map[string]interface{}{} + if name, _ := v["name"].(string); name != "" { + option["name"] = name + } + if hue, _ := v["hue"].(string); hue != "" { + option["hue"] = hue + } + if lightness, _ := v["lightness"].(string); lightness != "" { + option["lightness"] = lightness + } + if len(option) > 0 { + result = append(result, option) + } + } + } + return result +} + +func buildFieldBody(fieldName string, typeName string, property map[string]interface{}, uiType string, description string, isPrimary bool, isHidden bool) (map[string]interface{}, error) { + if isPrimary { + return nil, fmt.Errorf("base/v3 does not support setting primary field in field body") + } + if isHidden { + return nil, fmt.Errorf("base/v3 does not support hidden field creation in field body") + } + spec, err := resolveFieldTypeSpec(typeName) + if err != nil { + return nil, err + } + body := map[string]interface{}{ + "type": spec.Type, + "name": fieldName, + } + body = deepMergeMaps(body, cloneMap(spec.Extra)) + if description != "" { + _ = description + } + if uiType != "" { + switch strings.ToLower(uiType) { + case "currency": + body["type"] = "number" + body["style"] = map[string]interface{}{"type": "currency", "currency_code": "CNY", "formatter": "0.00"} + case "progress": + body["type"] = "number" + body["style"] = map[string]interface{}{"type": "progress", "min": 0, "max": 100, "color": "Blue"} + case "rating": + body["type"] = "number" + body["style"] = map[string]interface{}{"type": "rating", "icon": "star", "min": 1, "max": 5} + } + } + if property == nil { + return body, nil + } + property = cloneMap(property) + switch body["type"] { + case "number", "datetime", "created_at", "updated_at", "auto_number": + style, _ := body["style"].(map[string]interface{}) + if style == nil { + style = map[string]interface{}{} + } + if inner, ok := property["style"].(map[string]interface{}); ok { + style = deepMergeMaps(style, inner) + delete(property, "style") + } + style = deepMergeMaps(style, property) + if len(style) > 0 { + body["style"] = style + } + case "select": + if options, ok := property["options"]; ok { + body["options"] = normalizeSelectOptions(options) + delete(property, "options") + } + if multiple, ok := property["multiple"].(bool); ok { + body["multiple"] = multiple + delete(property, "multiple") + } + body = deepMergeMaps(body, property) + case "user": + if multiple, ok := property["multiple"].(bool); ok { + body["multiple"] = multiple + delete(property, "multiple") + } + case "link": + if tableID, _ := property["table_id"].(string); tableID != "" { + body["link_table"] = tableID + delete(property, "table_id") + } + if tableID, _ := property["link_table"].(string); tableID != "" { + body["link_table"] = tableID + delete(property, "link_table") + } + if multiple, ok := property["multiple"].(bool); ok { + _ = multiple + delete(property, "multiple") + } + if backName, _ := property["back_field_name"].(string); backName != "" { + body["bidirectional"] = true + body["bidirectional_link_field_name"] = backName + delete(property, "back_field_name") + } + body = deepMergeMaps(body, property) + case "formula": + if expr, _ := property["formula_expression"].(string); expr != "" { + body["expression"] = expr + delete(property, "formula_expression") + } + if expr, _ := property["expression"].(string); expr != "" { + body["expression"] = expr + delete(property, "expression") + } + body = deepMergeMaps(body, property) + default: + body = deepMergeMaps(body, property) + } + return body, nil +} + +func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{}, error) { + if rawFields != "" { + var fields []interface{} + if err := common.ParseJSON([]byte(rawFields), &fields); err != nil { + return nil, fmt.Errorf("--fields invalid JSON, must be a field definition array") + } + return fields, nil + } + specs, err := parseNamedTypeSpecs(rawFieldSpecs, "field-specs") + if err != nil { + return nil, err + } + fields := make([]interface{}, 0, len(specs)) + for _, spec := range specs { + body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false) + if err != nil { + return nil, fmt.Errorf("field %q: %w", spec.Name, err) + } + fields = append(fields, body) + } + return fields, nil +} + +func baseV3Path(parts ...string) string { + clean := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.Trim(part, "/") + if part != "" { + clean = append(clean, url.PathEscape(part)) + } + } + return baseV3ServicePath + "/" + strings.Join(clean, "/") +} + +func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { + queryParams := make(larkcore.QueryParams) + for k, v := range params { + queryParams.Set(k, fmt.Sprintf("%v", v)) + } + req := &larkcore.ApiReq{ + HttpMethod: strings.ToUpper(method), + ApiPath: path, + Body: data, + QueryParams: queryParams, + } + h := make(http.Header) + h.Set("X-App-Id", runtime.Config.AppID) + resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h)) + if err != nil { + return nil, err + } + if resp.StatusCode >= http.StatusBadRequest { + body := strings.TrimSpace(string(resp.RawBody)) + if body == "" { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body) + } + var result map[string]interface{} + dec := json.NewDecoder(bytes.NewReader(resp.RawBody)) + dec.UseNumber() + if err := dec.Decode(&result); err != nil { + return nil, fmt.Errorf("response parse error: %w", err) + } + return result, nil +} + +func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { + result, err := baseV3Raw(runtime, method, path, params, data) + return handleBaseAPIResult(result, err, "API call failed") +} + +func baseV3CallAny(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (interface{}, error) { + result, err := baseV3Raw(runtime, method, path, params, data) + return handleBaseAPIResultAny(result, err, "API call failed") +} + +func toInt(v interface{}) int { + switch n := v.(type) { + case int: + return n + case int64: + return int(n) + case float64: + return int(n) + case json.Number: + i, _ := n.Int64() + return int(i) + case string: + i, _ := strconv.Atoi(strings.TrimSpace(n)) + return i + default: + return 0 + } +} + +func toStringSlice(v interface{}) []string { + arr, ok := v.([]interface{}) + if !ok { + return nil + } + result := make([]string, 0, len(arr)) + for _, item := range arr { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result +} + +func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, limit int) ([]map[string]interface{}, int, error) { + if limit <= 0 { + return nil, 0, fmt.Errorf("limit must be greater than 0") + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables"), map[string]interface{}{"offset": offset, "limit": limit}, nil) + if err != nil { + return nil, 0, err + } + rawItems, _ := data["tables"].([]interface{}) + if len(rawItems) == 0 { + rawItems, _ = data["items"].([]interface{}) + } + if len(rawItems) == 0 { + if _, hasID := data["id"]; hasID { + rawItems = []interface{}{data} + } + } + items := make([]map[string]interface{}, 0, len(rawItems)) + for _, item := range rawItems { + if m, ok := item.(map[string]interface{}); ok { + items = append(items, m) + } + } + total := toInt(data["total"]) + if total == 0 { + total = len(items) + } + return items, total, nil +} + +func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) { + if limit <= 0 { + return nil, 0, fmt.Errorf("limit must be greater than 0") + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "fields"), map[string]interface{}{"offset": offset, "limit": limit}, nil) + if err != nil { + return nil, 0, err + } + rawItems, _ := data["fields"].([]interface{}) + items := make([]map[string]interface{}, 0, len(rawItems)) + for _, item := range rawItems { + if m, ok := item.(map[string]interface{}); ok { + items = append(items, m) + } + } + total := toInt(data["total"]) + if total == 0 { + total = len(items) + } + return items, total, nil +} + +func listAllViews(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) { + if limit <= 0 { + return nil, 0, fmt.Errorf("limit must be greater than 0") + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "views"), map[string]interface{}{"offset": offset, "limit": limit}, nil) + if err != nil { + return nil, 0, err + } + rawItems, _ := data["views"].([]interface{}) + items := make([]map[string]interface{}, 0, len(rawItems)) + for _, item := range rawItems { + if m, ok := item.(map[string]interface{}); ok { + items = append(items, m) + } + } + total := toInt(data["total"]) + if total == 0 { + total = len(items) + } + return items, total, nil +} + +func resolveFieldRef(fields []map[string]interface{}, ref string) (map[string]interface{}, error) { + for _, field := range fields { + if ref == fieldID(field) || ref == fieldName(field) { + return field, nil + } + } + return nil, fmt.Errorf("field %q not found", ref) +} + +func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) { + for _, table := range tables { + if ref == tableID(table) || ref == tableNameFromMap(table) { + return table, nil + } + } + return nil, fmt.Errorf("table %q not found", ref) +} + +func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) { + for _, view := range views { + if ref == viewID(view) || ref == viewName(view) { + return view, nil + } + } + return nil, fmt.Errorf("view %q not found", ref) +} + +func normalizeRecordInputs(raw string) ([]map[string]interface{}, error) { + var records []interface{} + if err := common.ParseJSON([]byte(raw), &records); err != nil { + return nil, fmt.Errorf("--records invalid JSON, must be a record array") + } + result := make([]map[string]interface{}, 0, len(records)) + for idx, item := range records { + record, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("record %d must be an object", idx+1) + } + if fields, ok := record["fields"].(map[string]interface{}); ok { + normalized := map[string]interface{}{"fields": fields} + if recordID, ok := record["record_id"].(string); ok && recordID != "" { + normalized["record_id"] = recordID + } + result = append(result, normalized) + continue + } + result = append(result, map[string]interface{}{"fields": record}) + } + return result, nil +} + +func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} { + if size <= 0 { + size = 1 + } + chunks := [][]map[string]interface{}{} + for start := 0; start < len(records); start += size { + end := start + size + if end > len(records) { + end = len(records) + } + chunks = append(chunks, records[start:end]) + } + return chunks +} + +func chunkStringIDs(ids []string, size int) [][]string { + if size <= 0 { + size = 1 + } + chunks := [][]string{} + for start := 0; start < len(ids); start += size { + end := start + size + if end > len(ids) { + end = len(ids) + } + chunks = append(chunks, ids[start:end]) + } + return chunks +} + +func fieldName(field map[string]interface{}) string { + if v, _ := field["name"].(string); v != "" { + return v + } + v, _ := field["field_name"].(string) + return v +} + +func fieldID(field map[string]interface{}) string { + if v, _ := field["id"].(string); v != "" { + return v + } + v, _ := field["field_id"].(string) + return v +} + +func fieldTypeName(field map[string]interface{}) string { + if v, _ := field["type"].(string); v != "" { + return v + } + return fmt.Sprintf("%v", field["type"]) +} + +func tableID(table map[string]interface{}) string { + if v, _ := table["id"].(string); v != "" { + return v + } + v, _ := table["table_id"].(string) + return v +} + +func tableNameFromMap(table map[string]interface{}) string { + if v, _ := table["name"].(string); v != "" { + return v + } + v, _ := table["table_name"].(string) + return v +} + +func viewID(view map[string]interface{}) string { + if v, _ := view["id"].(string); v != "" { + return v + } + v, _ := view["view_id"].(string) + return v +} + +func viewName(view map[string]interface{}) string { + if v, _ := view["name"].(string); v != "" { + return v + } + v, _ := view["view_name"].(string) + return v +} + +func viewType(view map[string]interface{}) string { + if v, _ := view["type"].(string); v != "" { + return v + } + v, _ := view["view_type"].(string) + return v +} + +func simplifyFields(fields []map[string]interface{}) []interface{} { + items := make([]interface{}, 0, len(fields)) + for _, field := range fields { + entry := map[string]interface{}{ + "field_id": fieldID(field), + "field_name": fieldName(field), + "type": fieldTypeName(field), + } + if style, ok := field["style"].(map[string]interface{}); ok && len(style) > 0 { + entry["style"] = style + } + if multiple, ok := field["multiple"].(bool); ok { + entry["multiple"] = multiple + } + items = append(items, entry) + } + return items +} + +func simplifyViews(views []map[string]interface{}) []interface{} { + items := make([]interface{}, 0, len(views)) + for _, view := range views { + items = append(items, map[string]interface{}{ + "view_id": viewID(view), + "view_name": viewName(view), + "view_type": viewType(view), + }) + } + return items +} + +func canonicalValue(v interface{}) string { + switch val := v.(type) { + case nil: + return "" + case []interface{}: + if len(val) == 1 { + return canonicalValue(val[0]) + } + case map[string]interface{}: + if id, ok := val["id"]; ok { + return canonicalValue(id) + } + if text, ok := val["text"]; ok { + return canonicalValue(text) + } + case string: + return strings.TrimSpace(val) + case float64: + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + } + b, _ := json.Marshal(v) + return string(b) +} + +func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) { + var tuples []interface{} + if err := common.ParseJSON([]byte(raw), &tuples); err != nil { + return nil, fmt.Errorf("--%s invalid JSON array", flagName) + } + result := make([]namedTypeSpec, 0, len(tuples)) + for idx, item := range tuples { + pair, ok := item.([]interface{}) + if !ok || len(pair) != 2 { + return nil, fmt.Errorf("--%s item %d must be [name, type]", flagName, idx+1) + } + name, ok1 := pair[0].(string) + typeName, ok2 := pair[1].(string) + if !ok1 || !ok2 { + return nil, fmt.Errorf("--%s item %d must be [string, string]", flagName, idx+1) + } + result = append(result, namedTypeSpec{Name: name, Type: typeName}) + } + return result, nil +} + +type namedTypeSpec struct { + Name string + Type string +} + +func selectRecordFields(items []map[string]interface{}, fields []string) []map[string]interface{} { + if len(fields) == 0 { + return items + } + result := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + entry := map[string]interface{}{} + if recordID, _ := item["record_id"].(string); recordID != "" { + entry["record_id"] = recordID + } + selected := map[string]interface{}{} + fieldMap, _ := item["fields"].(map[string]interface{}) + for _, name := range fields { + if value, ok := fieldMap[name]; ok { + selected[name] = value + } + } + entry["fields"] = selected + result = append(result, entry) + } + return result +} + +func compareScalar(left interface{}, right interface{}) int { + lf, lerr := strconv.ParseFloat(canonicalValue(left), 64) + rf, rerr := strconv.ParseFloat(canonicalValue(right), 64) + if lerr == nil && rerr == nil { + switch { + case lf < rf: + return -1 + case lf > rf: + return 1 + default: + return 0 + } + } + ls := canonicalValue(left) + rs := canonicalValue(right) + switch { + case ls < rs: + return -1 + case ls > rs: + return 1 + default: + return 0 + } +} + +func asSet(v interface{}) map[string]bool { + set := map[string]bool{} + switch val := v.(type) { + case []interface{}: + for _, item := range val { + set[canonicalValue(item)] = true + } + default: + if c := canonicalValue(v); c != "" { + set[c] = true + } + } + return set +} + +func valueEmpty(v interface{}) bool { + switch val := v.(type) { + case nil: + return true + case string: + return strings.TrimSpace(val) == "" + case []interface{}: + return len(val) == 0 + case map[string]interface{}: + return len(val) == 0 + default: + return canonicalValue(v) == "" + } +} + +func matchesCondition(value interface{}, condition []interface{}) bool { + if len(condition) < 2 { + return false + } + op, _ := condition[1].(string) + var target interface{} + if len(condition) > 2 { + target = condition[2] + } + switch op { + case "==": + return compareScalar(value, target) == 0 + case "!=": + return compareScalar(value, target) != 0 + case ">": + return compareScalar(value, target) > 0 + case ">=": + return compareScalar(value, target) >= 0 + case "<": + return compareScalar(value, target) < 0 + case "<=": + return compareScalar(value, target) <= 0 + case "empty": + return valueEmpty(value) + case "non_empty": + return !valueEmpty(value) + case "intersects": + left := asSet(value) + right := asSet(target) + for key := range left { + if right[key] { + return true + } + } + return false + case "disjoint": + left := asSet(value) + right := asSet(target) + for key := range left { + if right[key] { + return false + } + } + return true + default: + return false + } +} + +func normalizeFilterConfig(raw map[string]interface{}) (string, [][]interface{}) { + logic, _ := raw["logic"].(string) + if logic == "" { + logic, _ = raw["conjunction"].(string) + } + if logic == "" { + logic = "and" + } + rawConditions, _ := raw["conditions"].([]interface{}) + conditions := make([][]interface{}, 0, len(rawConditions)) + for _, item := range rawConditions { + switch cond := item.(type) { + case []interface{}: + conditions = append(conditions, cond) + case map[string]interface{}: + fieldName, ok := cond["field"] + if !ok { + fieldName = cond["field_name"] + } + conditions = append(conditions, []interface{}{fieldName, cond["operator"], cond["value"]}) + } + } + return logic, conditions +} + +func filterRecords(items []map[string]interface{}, filter map[string]interface{}) []map[string]interface{} { + logic, conditions := normalizeFilterConfig(filter) + if len(conditions) == 0 { + return items + } + result := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + fields, _ := item["fields"].(map[string]interface{}) + matches := logic != "or" + for _, cond := range conditions { + fieldRef := canonicalValue(cond[0]) + value := fields[fieldRef] + matched := matchesCondition(value, cond) + if logic == "or" { + matches = matches || matched + } else { + matches = matches && matched + } + } + if matches { + result = append(result, item) + } + } + return result +} + +func normalizeSortConfig(raw []interface{}) []map[string]interface{} { + result := make([]map[string]interface{}, 0, len(raw)) + for _, item := range raw { + if m, ok := item.(map[string]interface{}); ok { + entry := map[string]interface{}{} + if field, _ := m["field"].(string); field != "" { + entry["field"] = field + } else if field, _ := m["field_name"].(string); field != "" { + entry["field"] = field + } + if desc, ok := m["desc"].(bool); ok { + entry["desc"] = desc + } + result = append(result, entry) + } + } + return result +} + +func sortRecords(items []map[string]interface{}, sortConfig []interface{}) []map[string]interface{} { + normalized := normalizeSortConfig(sortConfig) + if len(normalized) == 0 { + return items + } + sorted := append([]map[string]interface{}{}, items...) + sort.SliceStable(sorted, func(i, j int) bool { + leftFields, _ := sorted[i]["fields"].(map[string]interface{}) + rightFields, _ := sorted[j]["fields"].(map[string]interface{}) + for _, spec := range normalized { + fieldRef, _ := spec["field"].(string) + desc, _ := spec["desc"].(bool) + cmp := compareScalar(leftFields[fieldRef], rightFields[fieldRef]) + if cmp == 0 { + continue + } + if desc { + return cmp > 0 + } + return cmp < 0 + } + return false + }) + return sorted +} + +func sleepBetweenBatches(index int, total int) { + if index < total-1 { + time.Sleep(600 * time.Millisecond) + } +} + +// ── Dashboard Block data_config normalization & validation ─────────── + +func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} { + if cfg == nil { + return nil + } + out := cloneMap(cfg) + // series[].rollup → 大写 + if arr, ok := out["series"].([]interface{}); ok { + for i, it := range arr { + if m, ok := it.(map[string]interface{}); ok { + if r, ok := m["rollup"].(string); ok && r != "" { + m["rollup"] = strings.ToUpper(strings.TrimSpace(r)) + } + arr[i] = m + } + } + out["series"] = arr + } + // group_by.sort 的 type/order → 小写 + if gb, ok := out["group_by"].([]interface{}); ok { + for i, g := range gb { + if m, ok := g.(map[string]interface{}); ok { + if md, ok := m["mode"].(string); ok { + m["mode"] = strings.ToLower(strings.TrimSpace(md)) + } + if sub, ok := m["sort"].(map[string]interface{}); ok { + if t, ok := sub["type"].(string); ok { + sub["type"] = strings.ToLower(strings.TrimSpace(t)) + } + if o, ok := sub["order"].(string); ok { + sub["order"] = strings.ToLower(strings.TrimSpace(o)) + } + m["sort"] = sub + } + gb[i] = m + } + } + out["group_by"] = gb + } + return out +} + +func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []string { + var errs []string + // table_name 必填 + if tn, _ := cfg["table_name"].(string); strings.TrimSpace(tn) == "" { + errs = append(errs, "缺少必填字段 table_name") + } + // series 与 count_all 互斥且必有其一 + _, hasSeries := cfg["series"] + _, hasCountAll := cfg["count_all"] + if !(hasSeries || hasCountAll) { + errs = append(errs, "series 与 count_all 二选一,至少提供其一") + } + if hasSeries && hasCountAll { + errs = append(errs, "series 与 count_all 互斥,不可同时存在") + } + // series 校验 + if hasSeries { + arr, ok := cfg["series"].([]interface{}) + if !ok || len(arr) == 0 { + errs = append(errs, "series 必须是非空数组") + } else { + // rollup 支持:SUM / MAX / MIN / AVERAGE(不支持 COUNTA;计数请使用 count_all) + allowed := map[string]bool{"SUM": true, "MAX": true, "MIN": true, "AVERAGE": true} + for i, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + errs = append(errs, fmt.Sprintf("series[%d] 必须是对象", i)) + continue + } + fn, _ := m["field_name"].(string) + if strings.TrimSpace(fn) == "" { + errs = append(errs, fmt.Sprintf("series[%d].field_name 不能为空", i)) + } + r, _ := m["rollup"].(string) + r = strings.ToUpper(strings.TrimSpace(r)) + if !allowed[r] { + errs = append(errs, fmt.Sprintf("series[%d].rollup 不在允许枚举内: %s", i, r)) + } + } + } + } + // group_by 最多 2 个,字段名必填,sort 合法 + if gb, ok := cfg["group_by"].([]interface{}); ok { + if len(gb) > 2 { + errs = append(errs, "group_by 最多支持 2 个维度") + } + for i, g := range gb { + m, ok := g.(map[string]interface{}) + if !ok { + errs = append(errs, fmt.Sprintf("group_by[%d] 必须是对象", i)) + continue + } + fn, _ := m["field_name"].(string) + if strings.TrimSpace(fn) == "" { + errs = append(errs, fmt.Sprintf("group_by[%d].field_name 不能为空", i)) + } + if sub, ok := m["sort"].(map[string]interface{}); ok { + t, _ := sub["type"].(string) + t = strings.ToLower(strings.TrimSpace(t)) + o, _ := sub["order"].(string) + o = strings.ToLower(strings.TrimSpace(o)) + if t != "group" && t != "value" && t != "view" { + errs = append(errs, fmt.Sprintf("group_by[%d].sort.type 仅支持 group|value|view", i)) + } + if o != "asc" && o != "desc" { + errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 仅支持 asc|desc", i)) + } + } + } + } + // filter 基本结构 + if f, ok := cfg["filter"].(map[string]interface{}); ok { + conj := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", f["conjunction"]))) + if conj == "" { + conj = "and" + } + if conj != "and" && conj != "or" { + errs = append(errs, "filter.conjunction 仅支持 and|or") + } + if conds, ok := f["conditions"].([]interface{}); ok { + allowedOps := map[string]bool{"is": true, "isnot": true, "contains": true, "doesnotcontain": true, "isempty": true, "isnotempty": true, "isgreater": true, "isgreaterequal": true, "isless": true, "islessequal": true} + for i, it := range conds { + m, ok := it.(map[string]interface{}) + if !ok { + errs = append(errs, fmt.Sprintf("filter.conditions[%d] 必须是对象", i)) + continue + } + fn, _ := m["field_name"].(string) + if strings.TrimSpace(fn) == "" { + errs = append(errs, fmt.Sprintf("filter.conditions[%d].field_name 不能为空", i)) + } + op, _ := m["operator"].(string) + key := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(op), " ", "")) + if !allowedOps[key] { + errs = append(errs, fmt.Sprintf("filter.conditions[%d].operator 不支持: %s", i, op)) + } + if key != "isempty" && key != "isnotempty" { + if _, has := m["value"]; !has { + errs = append(errs, fmt.Sprintf("filter.conditions[%d].value 缺失", i)) + } + } + } + } + } + return errs +} + +func formatDataConfigErrors(errs []string) error { + if len(errs) == 0 { + return nil + } + return fmt.Errorf("data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(errs, "\n- ")) +} diff --git a/shortcuts/base/helpers_test.go b/shortcuts/base/helpers_test.go new file mode 100644 index 00000000..61806c4a --- /dev/null +++ b/shortcuts/base/helpers_test.go @@ -0,0 +1,452 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "encoding/json" + "os" + "reflect" + "strings" + "testing" + "time" +) + +func TestParseHelpers(t *testing.T) { + tmpDir := t.TempDir() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd err=%v", err) + } + defer func() { _ = os.Chdir(cwd) }() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir err=%v", err) + } + tmp, err := os.CreateTemp(".", "base-json-*.json") + if err != nil { + t.Fatalf("temp file err=%v", err) + } + if _, err := tmp.WriteString(`{"name":"from-file"}`); err != nil { + t.Fatalf("write temp file err=%v", err) + } + _ = tmp.Close() + obj, err := parseJSONObject(`{"name":"demo"}`, "json") + if err != nil || obj["name"] != "demo" { + t.Fatalf("obj=%v err=%v", obj, err) + } + if _, err := parseJSONObject(`[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") { + t.Fatalf("err=%v", err) + } + obj, err = parseJSONObject("@"+tmp.Name(), "json") + if err != nil || obj["name"] != "from-file" { + t.Fatalf("file obj=%v err=%v", obj, err) + } + arr, err := parseJSONArray(`[1,2]`, "items") + if err != nil || len(arr) != 2 { + t.Fatalf("arr=%v err=%v", arr, err) + } + if _, err := parseJSONArray(`{"a":1}`, "items"); err == nil || !strings.Contains(err.Error(), "invalid JSON array") { + t.Fatalf("err=%v", err) + } + list, err := parseStringListFlexible("a, b, ,c", "fields") + if err != nil || !reflect.DeepEqual(list, []string{"a", "b", "c"}) { + t.Fatalf("list=%v err=%v", list, err) + } + list, err = parseStringListFlexible(`["x","y"]`, "fields") + if err != nil || !reflect.DeepEqual(list, []string{"x", "y"}) { + t.Fatalf("list=%v err=%v", list, err) + } + if _, err := parseStringListFlexible(`[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") { + t.Fatalf("err=%v", err) + } + if _, err := parseJSONValue("{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") { + t.Fatalf("err=%v", err) + } + if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) { + t.Fatalf("parseStringList mismatch") + } +} + +func TestMapHelpers(t *testing.T) { + dst := map[string]interface{}{"style": map[string]interface{}{"type": "number"}} + src := map[string]interface{}{"style": map[string]interface{}{"formatter": "0.00"}, "name": "Amount"} + merged := deepMergeMaps(dst, src) + style := merged["style"].(map[string]interface{}) + if style["type"] != "number" || style["formatter"] != "0.00" || merged["name"] != "Amount" { + t.Fatalf("merged=%v", merged) + } + cloned := cloneMap(merged) + cloned["name"] = "Changed" + if merged["name"] != "Amount" { + t.Fatalf("clone modified source: %v", merged) + } +} + +func TestResolveFieldTypeSpecAndNormalization(t *testing.T) { + spec, err := resolveFieldTypeSpec("currency") + if err != nil || spec.Type != "number" { + t.Fatalf("spec=%v err=%v", spec, err) + } + if _, ok := spec.Extra["style"]; !ok { + t.Fatalf("spec=%v", spec) + } + spec, err = resolveFieldTypeSpec("multi-select") + if err != nil || spec.Type != "select" || spec.Extra["multiple"] != true { + t.Fatalf("spec=%v err=%v", spec, err) + } + spec, err = resolveFieldTypeSpec("two_way_link") + if err != nil || spec.Type != "link" || spec.Extra["bidirectional"] != true { + t.Fatalf("spec=%v err=%v", spec, err) + } + if _, err := resolveFieldTypeSpec("unknown"); err == nil || !strings.Contains(err.Error(), "unsupported field type") { + t.Fatalf("err=%v", err) + } + if normalizeFieldTypeName(" text ") != "text" { + t.Fatalf("normalizeFieldTypeName failed") + } + if normalizeViewTypeName(" Kanban ") != "kanban" { + t.Fatalf("normalizeViewTypeName failed") + } + if normalizeViewTypeName("Custom") != "Custom" { + t.Fatalf("normalizeViewTypeName should preserve unknown values") + } + options := normalizeSelectOptions([]interface{}{"A", map[string]interface{}{"name": "B", "hue": "blue"}, 1}) + if len(options) != 2 { + t.Fatalf("options=%v", options) + } +} + +func TestBuildFieldBody(t *testing.T) { + if _, err := buildFieldBody("Name", "text", nil, "", "", true, false); err == nil || !strings.Contains(err.Error(), "primary") { + t.Fatalf("err=%v", err) + } + if _, err := buildFieldBody("Name", "text", nil, "", "", false, true); err == nil || !strings.Contains(err.Error(), "hidden") { + t.Fatalf("err=%v", err) + } + body, err := buildFieldBody("Amount", "number", map[string]interface{}{"precision": 2}, "currency", "", false, false) + if err != nil || body["type"] != "number" { + t.Fatalf("body=%v err=%v", body, err) + } + style := body["style"].(map[string]interface{}) + if style["type"] != "currency" || toInt(style["precision"]) != 2 { + t.Fatalf("style=%v", style) + } + body, err = buildFieldBody("Status", "multi-select", map[string]interface{}{"options": []interface{}{"Todo", map[string]interface{}{"name": "Done", "hue": "green"}}, "multiple": true}, "", "", false, false) + if err != nil || body["multiple"] != true { + t.Fatalf("body=%v err=%v", body, err) + } + if len(body["options"].([]interface{})) != 2 { + t.Fatalf("options=%v", body["options"]) + } + body, err = buildFieldBody("Owner", "user", map[string]interface{}{"multiple": false}, "", "", false, false) + if err != nil || body["multiple"] != false { + t.Fatalf("body=%v err=%v", body, err) + } + body, err = buildFieldBody("Relation", "link", map[string]interface{}{"table_id": "tbl_target", "back_field_name": "Back"}, "", "", false, false) + if err != nil || body["link_table"] != "tbl_target" || body["bidirectional"] != true || body["bidirectional_link_field_name"] != "Back" { + t.Fatalf("body=%v err=%v", body, err) + } + body, err = buildFieldBody("Expr", "formula", map[string]interface{}{"formula_expression": "1+1"}, "", "", false, false) + if err != nil || body["expression"] != "1+1" { + t.Fatalf("body=%v err=%v", body, err) + } +} + +func TestBuildTableFieldBodies(t *testing.T) { + fields, err := buildTableFieldBodies(`[{"name":"Name","type":"text"}]`, "") + if err != nil || len(fields) != 1 { + t.Fatalf("fields=%v err=%v", fields, err) + } + fields, err = buildTableFieldBodies("", `[["Name","text"],["Amount","currency"]]`) + if err != nil || len(fields) != 2 { + t.Fatalf("fields=%v err=%v", fields, err) + } + if _, err := buildTableFieldBodies("", `[["Name"]]`); err == nil || !strings.Contains(err.Error(), "must be [name, type]") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseV3Helpers(t *testing.T) { + if baseV3Path("/bases/", "app_1", "/tables/", "tbl_1") != "/open-apis/base/v3/bases/app_1/tables/tbl_1" { + t.Fatalf("baseV3Path mismatch") + } + if baseV3Path("bases", "app_1", "tables", "tbl/1", "fields", "fld?1", "views", "视图 1") != "/open-apis/base/v3/bases/app_1/tables/tbl%2F1/fields/fld%3F1/views/%E8%A7%86%E5%9B%BE%201" { + t.Fatalf("baseV3Path encode mismatch") + } + if toInt("42") != 42 || toInt(7.0) != 7 { + t.Fatalf("toInt mismatch") + } + if !reflect.DeepEqual(toStringSlice([]interface{}{"a", "b", 1}), []string{"a", "b"}) { + t.Fatalf("toStringSlice mismatch") + } +} + +func TestRecordAndChunkHelpers(t *testing.T) { + records, err := normalizeRecordInputs(`[{"record_id":"rec_1","fields":{"Name":"Alice"}},{"Name":"Bob"}]`) + if err != nil || len(records) != 2 { + t.Fatalf("records=%v err=%v", records, err) + } + if _, err := normalizeRecordInputs(`[1]`); err == nil || !strings.Contains(err.Error(), "must be an object") { + t.Fatalf("err=%v", err) + } + if len(chunkRecords(records, 1)) != 2 || len(chunkStringIDs([]string{"a", "b", "c"}, 2)) != 2 { + t.Fatalf("chunk helpers mismatch") + } +} + +func TestResolveAndSimplifyHelpers(t *testing.T) { + fields := []map[string]interface{}{{"id": "fld_1", "name": "Name", "type": "text"}, {"field_id": "fld_2", "field_name": "Age", "type": "number", "multiple": true}} + tables := []map[string]interface{}{{"id": "tbl_1", "name": "Orders"}} + views := []map[string]interface{}{{"id": "vew_1", "name": "Main", "type": "grid"}} + if field, err := resolveFieldRef(fields, "Age"); err != nil || fieldID(field) != "fld_2" { + t.Fatalf("field=%v err=%v", field, err) + } + if table, err := resolveTableRef(tables, "tbl_1"); err != nil || tableNameFromMap(table) != "Orders" { + t.Fatalf("table=%v err=%v", table, err) + } + if view, err := resolveViewRef(views, "Main"); err != nil || viewID(view) != "vew_1" { + t.Fatalf("view=%v err=%v", view, err) + } + if _, err := resolveViewRef(views, "Missing"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err=%v", err) + } + simplifiedFields := simplifyFields(fields) + if len(simplifiedFields) != 2 { + t.Fatalf("simplifiedFields=%v", simplifiedFields) + } + simplifiedViews := simplifyViews(views) + if len(simplifiedViews) != 1 { + t.Fatalf("simplifiedViews=%v", simplifiedViews) + } +} + +func TestFilterAndSortHelpers(t *testing.T) { + items := []map[string]interface{}{ + {"record_id": "rec_1", "fields": map[string]interface{}{"Name": "Alice", "Age": 18, "Tags": []interface{}{"a", "b"}}}, + {"record_id": "rec_2", "fields": map[string]interface{}{"Name": "Bob", "Age": 30, "Tags": []interface{}{"c"}}}, + } + selected := selectRecordFields(items, []string{"Name"}) + if selected[0]["record_id"] != "rec_1" { + t.Fatalf("selected=%v", selected) + } + if compareScalar(2, 10) >= 0 || compareScalar("b", "a") <= 0 { + t.Fatalf("compareScalar mismatch") + } + if canonicalValue([]interface{}{"x"}) != "x" || canonicalValue(map[string]interface{}{"text": "hello"}) != "hello" { + t.Fatalf("canonicalValue mismatch") + } + logic, conditions := normalizeFilterConfig(map[string]interface{}{ + "conjunction": "or", + "conditions": []interface{}{map[string]interface{}{"field_name": "Name", "operator": "==", "value": "Alice"}}, + }) + if logic != "or" || len(conditions) != 1 { + t.Fatalf("logic=%s conditions=%v", logic, conditions) + } + filtered := filterRecords(items, map[string]interface{}{ + "logic": "and", + "conditions": []interface{}{ + []interface{}{"Age", ">=", 18}, + []interface{}{"Tags", "intersects", []interface{}{"b"}}, + }, + }) + if len(filtered) != 1 || filtered[0]["record_id"] != "rec_1" { + t.Fatalf("filtered=%v", filtered) + } + sorted := sortRecords(items, []interface{}{map[string]interface{}{"field": "Age", "desc": true}}) + if sorted[0]["record_id"] != "rec_2" { + t.Fatalf("sorted=%v", sorted) + } + if !matchesCondition(nil, []interface{}{"Name", "empty"}) { + t.Fatalf("matchesCondition empty failed") + } +} + +func TestJSONInputHelpers(t *testing.T) { + if got, err := loadJSONInput(`{"name":"demo"}`, "json"); err != nil || got != `{"name":"demo"}` { + t.Fatalf("got=%q err=%v", got, err) + } + if _, err := loadJSONInput("@", "json"); err == nil || !strings.Contains(err.Error(), "file path cannot be empty") { + t.Fatalf("err=%v", err) + } + tmp := t.TempDir() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd err=%v", err) + } + defer func() { _ = os.Chdir(cwd) }() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir err=%v", err) + } + emptyPath := "empty.json" + if err := os.WriteFile(emptyPath, []byte(" \n"), 0o644); err != nil { + t.Fatalf("write empty file err=%v", err) + } + if _, err := loadJSONInput("@"+emptyPath, "json"); err == nil || !strings.Contains(err.Error(), "is empty") { + t.Fatalf("err=%v", err) + } + syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7}) + if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a JSON object/array directly") { + t.Fatalf("syntaxErr=%v", syntaxErr) + } + typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"}) + if !strings.Contains(typeErr.Error(), `field "filter_info"`) { + t.Fatalf("typeErr=%v", typeErr) + } +} + +func TestIdentifierAndValueHelpers(t *testing.T) { + if normalizeViewTypeName("") != "" || normalizeViewTypeName(" Gantt ") != "gantt" || normalizeViewTypeName("gallery") != "gallery" || normalizeViewTypeName("calendar") != "calendar" || normalizeViewTypeName("grid") != "grid" { + t.Fatalf("normalizeViewTypeName unexpected") + } + if tableID(map[string]interface{}{"table_id": "tbl_alt"}) != "tbl_alt" { + t.Fatalf("tableID alt key failed") + } + if tableNameFromMap(map[string]interface{}{"table_name": "Orders"}) != "Orders" { + t.Fatalf("tableName alt key failed") + } + if viewID(map[string]interface{}{"view_id": "vew_alt"}) != "vew_alt" { + t.Fatalf("viewID alt key failed") + } + if viewName(map[string]interface{}{"view_name": "Main"}) != "Main" { + t.Fatalf("viewName alt key failed") + } + if viewType(map[string]interface{}{"view_type": "grid"}) != "grid" { + t.Fatalf("viewType alt key failed") + } + if !valueEmpty(nil) || !valueEmpty(" ") || !valueEmpty([]interface{}{}) || !valueEmpty(map[string]interface{}{}) { + t.Fatalf("valueEmpty empty cases failed") + } + if valueEmpty(0) { + t.Fatalf("valueEmpty should keep numeric zero as non-empty") + } +} + +func TestConditionHelpers(t *testing.T) { + if matchesCondition("x", []interface{}{"Name"}) { + t.Fatalf("short condition should be false") + } + cases := []struct { + name string + value interface{} + cond []interface{} + want bool + }{ + {"eq", 1.0, []interface{}{"Age", "==", 1.0}, true}, + {"neq", 1.0, []interface{}{"Age", "!=", 2.0}, true}, + {"gt", 3.0, []interface{}{"Age", ">", 2.0}, true}, + {"gte", 3.0, []interface{}{"Age", ">=", 3.0}, true}, + {"lt", 1.0, []interface{}{"Age", "<", 2.0}, true}, + {"lte", 1.0, []interface{}{"Age", "<=", 1.0}, true}, + {"empty", " ", []interface{}{"Name", "empty"}, true}, + {"non_empty", "Alice", []interface{}{"Name", "non_empty"}, true}, + {"intersects", []interface{}{"a", "b"}, []interface{}{"Tags", "intersects", []interface{}{"c", "b"}}, true}, + {"disjoint", []interface{}{"a", "b"}, []interface{}{"Tags", "disjoint", []interface{}{"c", "d"}}, true}, + {"unknown", "Alice", []interface{}{"Name", "contains", "A"}, false}, + } + for _, tt := range cases { + if got := matchesCondition(tt.value, tt.cond); got != tt.want { + t.Fatalf("%s got=%v want=%v", tt.name, got, tt.want) + } + } +} + +func TestSleepBetweenBatches(t *testing.T) { + start := time.Now() + sleepBetweenBatches(0, 1) + if elapsed := time.Since(start); elapsed > 200*time.Millisecond { + t.Fatalf("unexpected sleep for last batch: %v", elapsed) + } + start = time.Now() + sleepBetweenBatches(0, 2) + if elapsed := time.Since(start); elapsed < 550*time.Millisecond { + t.Fatalf("expected sleep between batches, got %v", elapsed) + } +} + +func TestResolveFieldTypeSpecMoreAliases(t *testing.T) { + cases := []struct { + input string + wantType string + check func(fieldTypeSpec) bool + }{ + {"", "", func(spec fieldTypeSpec) bool { return false }}, + {"progress", "number", func(spec fieldTypeSpec) bool { + return spec.Extra["style"].(map[string]interface{})["type"] == "progress" + }}, + {"rating", "number", func(spec fieldTypeSpec) bool { return spec.Extra["style"].(map[string]interface{})["type"] == "rating" }}, + {"single-select", "select", func(spec fieldTypeSpec) bool { return spec.Extra["multiple"] == false }}, + {"group-chat", "user", func(spec fieldTypeSpec) bool { return spec.Extra["multiple"] == true }}, + {"auto-number", "auto_number", func(spec fieldTypeSpec) bool { _, ok := spec.Extra["style"]; return ok }}, + {"created-time", "created_at", func(spec fieldTypeSpec) bool { + return spec.Extra["style"].(map[string]interface{})["format"] == "yyyy/MM/dd" + }}, + {"modified_time", "updated_at", func(spec fieldTypeSpec) bool { + return spec.Extra["style"].(map[string]interface{})["format"] == "yyyy/MM/dd" + }}, + } + if _, err := resolveFieldTypeSpec(cases[0].input); err == nil || !strings.Contains(err.Error(), "cannot be empty") { + t.Fatalf("err=%v", err) + } + for _, tt := range cases[1:] { + spec, err := resolveFieldTypeSpec(tt.input) + if err != nil || spec.Type != tt.wantType || !tt.check(spec) { + t.Fatalf("input=%s spec=%v err=%v", tt.input, spec, err) + } + } +} + +func TestNamedSpecAndSortHelpers(t *testing.T) { + specs, err := parseNamedTypeSpecs(`[["Name","text"],["Amount","number"]]`, "fields") + if err != nil || len(specs) != 2 || specs[1].Type != "number" { + t.Fatalf("specs=%v err=%v", specs, err) + } + if _, err := parseNamedTypeSpecs(`{}`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON array") { + t.Fatalf("err=%v", err) + } + if _, err := parseNamedTypeSpecs(`[["Name"]]`, "fields"); err == nil || !strings.Contains(err.Error(), "must be [name, type]") { + t.Fatalf("err=%v", err) + } + if _, err := parseNamedTypeSpecs(`[[1,"text"]]`, "fields"); err == nil || !strings.Contains(err.Error(), "must be [string, string]") { + t.Fatalf("err=%v", err) + } + normalized := normalizeSortConfig([]interface{}{ + map[string]interface{}{"field_name": "Priority", "desc": true}, + map[string]interface{}{"field": "Amount"}, + "ignored", + }) + if len(normalized) != 2 || normalized[0]["field"] != "Priority" || normalized[0]["desc"] != true || normalized[1]["field"] != "Amount" { + t.Fatalf("normalized=%v", normalized) + } +} + +func TestCanonicalSelectAndCompareHelpers(t *testing.T) { + if fieldTypeName(map[string]interface{}{"kind": "text"}) != "" { + t.Fatalf("fieldTypeName fallback mismatch") + } + if got := canonicalValue(map[string]interface{}{"id": "opt_1"}); got != "opt_1" { + t.Fatalf("canonical id=%q", got) + } + if got := canonicalValue(1.5); got != "1.5" { + t.Fatalf("canonical float=%q", got) + } + if got := canonicalValue([]interface{}{"x", "y"}); !strings.Contains(got, "x") || !strings.Contains(got, "y") { + t.Fatalf("canonical array=%q", got) + } + if compareScalar("2", 2.0) != 0 || compareScalar("a", "b") >= 0 { + t.Fatalf("compareScalar mismatch") + } + set := asSet(" Alice ") + if !set["Alice"] || len(set) != 1 { + t.Fatalf("set=%v", set) + } + selected := selectRecordFields([]map[string]interface{}{{"record_id": "rec_1", "fields": map[string]interface{}{"Name": "Alice"}}}, nil) + if selected[0]["fields"].(map[string]interface{})["Name"] != "Alice" { + t.Fatalf("selected=%v", selected) + } + if _, err := resolveFieldRef([]map[string]interface{}{{"id": "fld_1", "name": "Name"}}, "Missing"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err=%v", err) + } + if _, err := resolveTableRef([]map[string]interface{}{{"id": "tbl_1", "name": "Orders"}}, "Missing"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err=%v", err) + } +} diff --git a/shortcuts/base/record_delete.go b/shortcuts/base/record_delete.go new file mode 100644 index 00000000..a4e3bffb --- /dev/null +++ b/shortcuts/base/record_delete.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRecordDelete = common.Shortcut{ + Service: "base", + Command: "+record-delete", + Description: "Delete a record by ID", + Risk: "high-risk-write", + Scopes: []string{"base:record:delete"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), recordRefFlag(true)}, + DryRun: dryRunRecordDelete, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeRecordDelete(runtime) + }, +} diff --git a/shortcuts/base/record_get.go b/shortcuts/base/record_get.go new file mode 100644 index 00000000..b54c5466 --- /dev/null +++ b/shortcuts/base/record_get.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRecordGet = common.Shortcut{ + Service: "base", + Command: "+record-get", + Description: "Get a record by ID", + Risk: "read", + Scopes: []string{"base:record:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + recordRefFlag(true), + }, + DryRun: dryRunRecordGet, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeRecordGet(runtime) + }, +} diff --git a/shortcuts/base/record_history_list.go b/shortcuts/base/record_history_list.go new file mode 100644 index 00000000..d9ce8f4e --- /dev/null +++ b/shortcuts/base/record_history_list.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRecordHistoryList = common.Shortcut{ + Service: "base", + Command: "+record-history-list", + Description: "List record change history", + Risk: "read", + Scopes: []string{"base:record:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + recordRefFlag(true), + {Name: "max-version", Type: "int", Desc: "max version for next page"}, + {Name: "page-size", Type: "int", Default: "30", Desc: "pagination size"}, + }, + DryRun: dryRunRecordHistoryList, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + params := map[string]interface{}{ + "table_id": baseTableID(runtime), + "record_id": runtime.Str("record-id"), + "page_size": runtime.Int("page-size"), + } + if value := runtime.Int("max-version"); value > 0 { + params["max_version"] = value + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "record_history"), params, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/record_list.go b/shortcuts/base/record_list.go new file mode 100644 index 00000000..815cfb28 --- /dev/null +++ b/shortcuts/base/record_list.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRecordList = common.Shortcut{ + Service: "base", + Command: "+record-list", + Description: "List records in a table", + Risk: "read", + Scopes: []string{"base:record:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "view-id", Desc: "view ID"}, + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "100", Desc: "pagination size"}, + }, + DryRun: dryRunRecordList, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeRecordList(runtime) + }, +} diff --git a/shortcuts/base/record_ops.go b/shortcuts/base/record_ops.go new file mode 100644 index 00000000..280b1c58 --- /dev/null +++ b/shortcuts/base/record_ops.go @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + params := map[string]interface{}{"offset": offset, "limit": limit} + if viewID := runtime.Str("view-id"); viewID != "" { + params["view_id"] = viewID + } + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records"). + Params(params). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)) +} + +func dryRunRecordGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("record_id", runtime.Str("record-id")) +} + +func dryRunRecordUpsert(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, _ := parseJSONObject(runtime.Str("json"), "json") + if recordID := runtime.Str("record-id"); recordID != "" { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("record_id", recordID) + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/records"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)) +} + +func dryRunRecordDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("record_id", runtime.Str("record-id")) +} + +func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{ + "table_id": baseTableID(runtime), + "record_id": runtime.Str("record-id"), + "page_size": runtime.Int("page-size"), + } + if value := runtime.Int("max-version"); value > 0 { + params["max_version"] = value + } + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/record_history"). + Params(params). + Set("base_token", runtime.Str("base-token")) +} + +func validateRecordJSON(runtime *common.RuntimeContext) error { + return nil +} + +func executeRecordList(runtime *common.RuntimeContext) error { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + params := map[string]interface{}{"offset": offset, "limit": limit} + if viewID := runtime.Str("view-id"); viewID != "" { + params["view_id"] = viewID + } + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records"), params, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil +} + +func executeRecordGet(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil +} + +func executeRecordUpsert(runtime *common.RuntimeContext) error { + body, err := parseJSONObject(runtime.Str("json"), "json") + if err != nil { + return err + } + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + if recordID := runtime.Str("record-id"); recordID != "" { + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", baseToken, "tables", tableIDValue, "records", recordID), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"record": data, "updated": true}, nil) + return nil + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "records"), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"record": data, "created": true}, nil) + return nil +} + +func executeRecordDelete(runtime *common.RuntimeContext) error { + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"deleted": true, "record_id": runtime.Str("record-id")}, nil) + return nil +} diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go new file mode 100644 index 00000000..68959421 --- /dev/null +++ b/shortcuts/base/record_upload_attachment.go @@ -0,0 +1,261 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + baseAttachmentUploadMaxFileSize = 20 * 1024 * 1024 + baseAttachmentParentType = "bitable_file" +) + +var BaseRecordUploadAttachment = common.Shortcut{ + Service: "base", + Command: "+record-upload-attachment", + Description: "Upload a local file to a Base attachment field and write it into the target record", + Risk: "write", + Scopes: []string{"base:record:update", "base:field:read", "docs:document.media:upload"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + recordRefFlag(true), + fieldRefFlag(true), + {Name: "file", Desc: "local file path (max 20MB)", Required: true}, + {Name: "name", Desc: "attachment file name (default: local file name)"}, + }, + DryRun: dryRunRecordUploadAttachment, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeRecordUploadAttachment(runtime) + }, +} + +func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + filePath := runtime.Str("file") + fileName := strings.TrimSpace(runtime.Str("name")) + if fileName == "" { + fileName = filepath.Base(filePath) + } + return common.NewDryRunAPI(). + Desc("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array"). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id"). + Desc("[1] Read target field and ensure it is an attachment field"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("field_id", runtime.Str("field-id")). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). + Desc("[2] Read current record to preserve existing attachments in the target cell"). + Set("record_id", runtime.Str("record-id")). + POST("/open-apis/drive/v1/medias/upload_all"). + Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)"). + Body(map[string]interface{}{ + "file_name": fileName, + "parent_type": baseAttachmentParentType, + "parent_node": runtime.Str("base-token"), + "file": "@" + filePath, + }). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id"). + Desc("[4] Update the target attachment cell with existing attachments plus the uploaded file token"). + Body(map[string]interface{}{ + "": []interface{}{ + map[string]interface{}{ + "file_token": "", + "name": "", + "deprecated_set_attachment": true, + }, + map[string]interface{}{ + "file_token": "", + "name": fileName, + "deprecated_set_attachment": true, + }, + }, + }) +} + +func executeRecordUploadAttachment(runtime *common.RuntimeContext) error { + filePath := runtime.Str("file") + safeFilePath, err := validate.SafeInputPath(filePath) + if err != nil { + return output.ErrValidation("unsafe file path: %s", err) + } + filePath = safeFilePath + + fileInfo, err := os.Stat(filePath) + if err != nil { + return output.ErrValidation("file not found: %s", filePath) + } + if fileInfo.Size() > baseAttachmentUploadMaxFileSize { + return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileInfo.Size())/1024/1024) + } + + fileName := strings.TrimSpace(runtime.Str("name")) + if fileName == "" { + fileName = filepath.Base(filePath) + } + + field, err := fetchBaseField(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("field-id")) + if err != nil { + return err + } + if normalized := normalizeFieldTypeName(fieldTypeName(field)); normalized != "attachment" { + return output.ErrValidation("field %q is type %q, expected attachment", fieldName(field), normalized) + } + + record, err := fetchBaseRecord(runtime, runtime.Str("base-token"), baseTableID(runtime), runtime.Str("record-id")) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field)) + + attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size()) + if err != nil { + return err + } + + attachments, err := mergeRecordAttachments(record, fieldName(field), attachment) + if err != nil { + return err + } + + body := map[string]interface{}{ + fieldName(field): attachments, + } + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "records", runtime.Str("record-id")), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{ + "record": data, + "attachment": attachment, + "attachments": attachments, + "updated": true, + }, nil) + return nil +} + +func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fieldRef string) (map[string]interface{}, error) { + return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil) +} + +func fetchBaseRecord(runtime *common.RuntimeContext, baseToken, tableIDValue, recordID string) (map[string]interface{}, error) { + return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "records", recordID), nil, nil) +} + +func mergeRecordAttachments(record map[string]interface{}, fieldName string, uploaded map[string]interface{}) ([]interface{}, error) { + fields, _ := record["fields"].(map[string]interface{}) + if fields == nil { + return []interface{}{uploaded}, nil + } + current, exists := fields[fieldName] + if !exists || util.IsNil(current) { + return []interface{}{uploaded}, nil + } + items, ok := current.([]interface{}) + if !ok { + return nil, output.ErrValidation("record field %q has unexpected attachment payload type %T", fieldName, current) + } + merged := make([]interface{}, 0, len(items)+1) + for _, item := range items { + attachment, ok := item.(map[string]interface{}) + if !ok { + return nil, output.ErrValidation("record field %q contains unexpected attachment item type %T", fieldName, item) + } + merged = append(merged, normalizeAttachmentForPatch(attachment)) + } + merged = append(merged, uploaded) + return merged, nil +} + +func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]interface{} { + normalized := map[string]interface{}{} + if fileToken, _ := attachment["file_token"].(string); fileToken != "" { + normalized["file_token"] = fileToken + } + if name, _ := attachment["name"].(string); name != "" { + normalized["name"] = name + } + if mimeType, _ := attachment["mime_type"].(string); mimeType != "" { + normalized["mime_type"] = mimeType + } + if size, ok := attachment["size"]; ok && !util.IsNil(size) { + normalized["size"] = size + } + if imageWidth, ok := attachment["image_width"]; ok && !util.IsNil(imageWidth) { + normalized["image_width"] = imageWidth + } + if imageHeight, ok := attachment["image_height"]; ok && !util.IsNil(imageHeight) { + normalized["image_height"] = imageHeight + } + normalized["deprecated_set_attachment"] = true + return normalized +} + +func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, output.ErrValidation("cannot open file: %v", err) + } + defer f.Close() + + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddField("parent_type", baseAttachmentParentType) + fd.AddField("parent_node", baseToken) + fd.AddField("size", fmt.Sprintf("%d", fileSize)) + fd.AddFile("file", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/medias/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return nil, err + } + return nil, output.ErrNetwork("upload failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) + } + + code, _ := util.ToFloat64(result["code"]) + if code != 0 { + msg, _ := result["msg"].(string) + return nil, output.ErrAPI(int(code), fmt.Sprintf("upload failed: [%d] %s", int(code), msg), result["error"]) + } + + data, _ := result["data"].(map[string]interface{}) + fileToken, _ := data["file_token"].(string) + if fileToken == "" { + return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + } + + attachment := map[string]interface{}{ + "file_token": fileToken, + "name": fileName, + "deprecated_set_attachment": true, + } + return attachment, nil +} diff --git a/shortcuts/base/record_upsert.go b/shortcuts/base/record_upsert.go new file mode 100644 index 00000000..0ff68309 --- /dev/null +++ b/shortcuts/base/record_upsert.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseRecordUpsert = common.Shortcut{ + Service: "base", + Command: "+record-upsert", + Description: "Create or update a record", + Risk: "write", + Scopes: []string{"base:record:create", "base:record:update"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + recordRefFlag(false), + {Name: "json", Desc: "record JSON object", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateRecordJSON(runtime) + }, + DryRun: dryRunRecordUpsert, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeRecordUpsert(runtime) + }, +} diff --git a/shortcuts/base/shortcuts.go b/shortcuts/base/shortcuts.go new file mode 100644 index 00000000..fea4ebd7 --- /dev/null +++ b/shortcuts/base/shortcuts.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all base shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + BaseTableList, + BaseTableGet, + BaseTableCreate, + BaseTableUpdate, + BaseTableDelete, + BaseFieldList, + BaseFieldGet, + BaseFieldCreate, + BaseFieldUpdate, + BaseFieldDelete, + BaseFieldSearchOptions, + BaseViewList, + BaseViewGet, + BaseViewCreate, + BaseViewDelete, + BaseViewGetFilter, + BaseViewSetFilter, + BaseViewGetGroup, + BaseViewSetGroup, + BaseViewGetSort, + BaseViewSetSort, + BaseViewGetTimebar, + BaseViewSetTimebar, + BaseViewGetCard, + BaseViewSetCard, + BaseViewRename, + BaseRecordList, + BaseRecordGet, + BaseRecordUpsert, + BaseRecordUploadAttachment, + BaseRecordDelete, + BaseRecordHistoryList, + BaseBaseGet, + BaseBaseCopy, + BaseBaseCreate, + BaseRoleCreate, + BaseRoleDelete, + BaseRoleUpdate, + BaseRoleList, + BaseRoleGet, + BaseAdvpermEnable, + BaseAdvpermDisable, + BaseWorkflowList, + BaseWorkflowGet, + BaseWorkflowCreate, + BaseWorkflowUpdate, + BaseWorkflowEnable, + BaseWorkflowDisable, + BaseDataQuery, + BaseFormCreate, + BaseFormDelete, + BaseFormsList, + BaseFormUpdate, + BaseFormGet, + BaseFormQuestionsCreate, + BaseFormQuestionsDelete, + BaseFormQuestionsUpdate, + BaseFormQuestionsList, + BaseDashboardList, + BaseDashboardGet, + BaseDashboardCreate, + BaseDashboardUpdate, + BaseDashboardDelete, + BaseDashboardBlockList, + BaseDashboardBlockGet, + BaseDashboardBlockCreate, + BaseDashboardBlockUpdate, + BaseDashboardBlockDelete, + } +} diff --git a/shortcuts/base/table_create.go b/shortcuts/base/table_create.go new file mode 100644 index 00000000..3bb65a8a --- /dev/null +++ b/shortcuts/base/table_create.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseTableCreate = common.Shortcut{ + Service: "base", + Command: "+table-create", + Description: "Create a table and optional fields/views", + Risk: "write", + Scopes: []string{"base:table:create", "base:field:read", "base:field:create", "base:field:update", "base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "name", Desc: "table name", Required: true}, + {Name: "view", Desc: "view JSON object/array for create"}, + {Name: "fields", Desc: "field JSON array for create"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateTableCreate(runtime) + }, + DryRun: dryRunTableCreate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCreate(runtime) + }, +} diff --git a/shortcuts/base/table_delete.go b/shortcuts/base/table_delete.go new file mode 100644 index 00000000..58ad5010 --- /dev/null +++ b/shortcuts/base/table_delete.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseTableDelete = common.Shortcut{ + Service: "base", + Command: "+table-delete", + Description: "Delete a table by ID or name", + Risk: "high-risk-write", + Scopes: []string{"base:table:delete"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)}, + DryRun: dryRunTableDelete, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableDelete(runtime) + }, +} diff --git a/shortcuts/base/table_get.go b/shortcuts/base/table_get.go new file mode 100644 index 00000000..c427b450 --- /dev/null +++ b/shortcuts/base/table_get.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseTableGet = common.Shortcut{ + Service: "base", + Command: "+table-get", + Description: "Get a table by ID or name", + Risk: "read", + Scopes: []string{"base:table:read", "base:field:read", "base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true)}, + DryRun: dryRunTableGet, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableGet(runtime) + }, +} diff --git a/shortcuts/base/table_list.go b/shortcuts/base/table_list.go new file mode 100644 index 00000000..01de1572 --- /dev/null +++ b/shortcuts/base/table_list.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseTableList = common.Shortcut{ + Service: "base", + Command: "+table-list", + Description: "List tables in a base", + Risk: "read", + Scopes: []string{"base:table:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "50", Desc: "pagination limit"}, + }, + DryRun: dryRunTableList, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableList(runtime) + }, +} diff --git a/shortcuts/base/table_ops.go b/shortcuts/base/table_ops.go new file mode 100644 index 00000000..04482396 --- /dev/null +++ b/shortcuts/base/table_ops.go @@ -0,0 +1,216 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/shortcuts/common" +) + +func dryRunTableList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 100) + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables"). + Params(map[string]interface{}{"offset": offset, "limit": limit}). + Set("base_token", runtime.Str("base-token")) +} + +func dryRunTableGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")) +} + +func dryRunTableCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/tables"). + Body(map[string]interface{}{"name": runtime.Str("name")}). + Set("base_token", runtime.Str("base-token")) +} + +func dryRunTableUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id"). + Body(map[string]interface{}{"name": runtime.Str("name")}). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")) +} + +func dryRunTableDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")) +} + +func validateTableCreate(runtime *common.RuntimeContext) error { + return nil +} + +func executeTableList(runtime *common.RuntimeContext) error { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 100) + tables, total, err := listAllTables(runtime, runtime.Str("base-token"), offset, limit) + if err != nil { + return err + } + if total == 0 { + total = len(tables) + } + items := make([]interface{}, 0, len(tables)) + for _, table := range tables { + items = append(items, map[string]interface{}{"table_id": tableID(table), "table_name": tableNameFromMap(table)}) + } + runtime.Out(map[string]interface{}{"items": items, "offset": offset, "limit": limit, "count": len(items), "total": total}, nil) + return nil +} + +func executeTableGet(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := runtime.Str("table-id") + table, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue), nil, nil) + if err != nil { + return err + } + fields, err := listEveryField(runtime, baseToken, tableIDValue) + if err != nil { + return err + } + views, err := listEveryView(runtime, baseToken, tableIDValue) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{ + "table": table, + "fields": simplifyFields(fields), + "views": simplifyViews(views), + }, nil) + return nil +} + +func executeTableCreate(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + created, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables"), nil, map[string]interface{}{"name": runtime.Str("name")}) + if err != nil { + return err + } + result := map[string]interface{}{"table": created} + tableIDValue := tableID(created) + if tableIDValue != "" && runtime.Str("fields") != "" { + fieldItems, err := parseJSONArray(runtime.Str("fields"), "fields") + if err != nil { + return err + } + defaultFields, err := listEveryField(runtime, baseToken, tableIDValue) + if err != nil { + return err + } + createdFields := []interface{}{} + for idx, item := range fieldItems { + body, ok := item.(map[string]interface{}) + if !ok { + return fmt.Errorf("--fields item %d must be an object", idx+1) + } + if idx == 0 && len(defaultFields) > 0 { + fieldData, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldID(defaultFields[0])), nil, body) + if err != nil { + return err + } + createdFields = append(createdFields, fieldData) + continue + } + fieldData, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields"), nil, body) + if err != nil { + return err + } + createdFields = append(createdFields, fieldData) + } + result["fields"] = createdFields + } + if tableIDValue != "" && runtime.Str("view") != "" { + viewItems, err := parseObjectList(runtime.Str("view"), "view") + if err != nil { + return err + } + createdViews := []interface{}{} + for _, body := range viewItems { + viewData, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "views"), nil, body) + if err != nil { + return err + } + createdViews = append(createdViews, viewData) + } + result["views"] = createdViews + } + runtime.Out(result, nil) + return nil +} + +func listEveryField(runtime *common.RuntimeContext, baseToken, tableID string) ([]map[string]interface{}, error) { + const pageLimit = 100 + offset := 0 + items := []map[string]interface{}{} + for { + batch, total, err := listAllFields(runtime, baseToken, tableID, offset, pageLimit) + if err != nil { + return nil, err + } + items = append(items, batch...) + if len(batch) == 0 || len(batch) < pageLimit || (total > 0 && len(items) >= total) { + break + } + offset += len(batch) + } + return items, nil +} + +func listEveryView(runtime *common.RuntimeContext, baseToken, tableID string) ([]map[string]interface{}, error) { + const pageLimit = 100 + offset := 0 + items := []map[string]interface{}{} + for { + batch, total, err := listAllViews(runtime, baseToken, tableID, offset, pageLimit) + if err != nil { + return nil, err + } + items = append(items, batch...) + if len(batch) == 0 || len(batch) < pageLimit || (total > 0 && len(items) >= total) { + break + } + offset += len(batch) + } + return items, nil +} + +func executeTableUpdate(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := runtime.Str("table-id") + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", baseToken, "tables", tableIDValue), nil, map[string]interface{}{"name": runtime.Str("name")}) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"table": data, "updated": true}, nil) + return nil +} + +func executeTableDelete(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := runtime.Str("table-id") + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseToken, "tables", tableIDValue), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"deleted": true, "table_id": tableIDValue, "table_name": tableIDValue}, nil) + return nil +} diff --git a/shortcuts/base/table_update.go b/shortcuts/base/table_update.go new file mode 100644 index 00000000..12d453e4 --- /dev/null +++ b/shortcuts/base/table_update.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseTableUpdate = common.Shortcut{ + Service: "base", + Command: "+table-update", + Description: "Rename a table by ID or name", + Risk: "write", + Scopes: []string{"base:table:update"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "name", Desc: "new table name", Required: true}, + }, + DryRun: dryRunTableUpdate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableUpdate(runtime) + }, +} diff --git a/shortcuts/base/view_create.go b/shortcuts/base/view_create.go new file mode 100644 index 00000000..3722e41d --- /dev/null +++ b/shortcuts/base/view_create.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewCreate = common.Shortcut{ + Service: "base", + Command: "+view-create", + Description: "Create one or more views", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "json", Desc: "view JSON object/array", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateViewCreate(runtime) + }, + DryRun: dryRunViewCreate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewCreate(runtime) + }, +} diff --git a/shortcuts/base/view_delete.go b/shortcuts/base/view_delete.go new file mode 100644 index 00000000..5db39902 --- /dev/null +++ b/shortcuts/base/view_delete.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewDelete = common.Shortcut{ + Service: "base", + Command: "+view-delete", + Description: "Delete a view by ID or name", + Risk: "high-risk-write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewDelete, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewDelete(runtime) + }, +} diff --git a/shortcuts/base/view_get.go b/shortcuts/base/view_get.go new file mode 100644 index 00000000..635c57cb --- /dev/null +++ b/shortcuts/base/view_get.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewGet = common.Shortcut{ + Service: "base", + Command: "+view-get", + Description: "Get a view by ID or name", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGet, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewGet(runtime) + }, +} diff --git a/shortcuts/base/view_get_card.go b/shortcuts/base/view_get_card.go new file mode 100644 index 00000000..10fa43c7 --- /dev/null +++ b/shortcuts/base/view_get_card.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewGetCard = common.Shortcut{ + Service: "base", + Command: "+view-get-card", + Description: "Get view card configuration", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetCard, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewGetProperty(runtime, "card", "card") + }, +} diff --git a/shortcuts/base/view_get_filter.go b/shortcuts/base/view_get_filter.go new file mode 100644 index 00000000..60ef0efe --- /dev/null +++ b/shortcuts/base/view_get_filter.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewGetFilter = common.Shortcut{ + Service: "base", + Command: "+view-get-filter", + Description: "Get view filter configuration", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetFilter, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewGetProperty(runtime, "filter", "filter") + }, +} diff --git a/shortcuts/base/view_get_group.go b/shortcuts/base/view_get_group.go new file mode 100644 index 00000000..c201786f --- /dev/null +++ b/shortcuts/base/view_get_group.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewGetGroup = common.Shortcut{ + Service: "base", + Command: "+view-get-group", + Description: "Get view group configuration", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetGroup, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewGetProperty(runtime, "group", "group") + }, +} diff --git a/shortcuts/base/view_get_sort.go b/shortcuts/base/view_get_sort.go new file mode 100644 index 00000000..99c7797d --- /dev/null +++ b/shortcuts/base/view_get_sort.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewGetSort = common.Shortcut{ + Service: "base", + Command: "+view-get-sort", + Description: "Get view sort configuration", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetSort, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewGetProperty(runtime, "sort", "sort") + }, +} diff --git a/shortcuts/base/view_get_timebar.go b/shortcuts/base/view_get_timebar.go new file mode 100644 index 00000000..7575b50f --- /dev/null +++ b/shortcuts/base/view_get_timebar.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewGetTimebar = common.Shortcut{ + Service: "base", + Command: "+view-get-timebar", + Description: "Get view timebar configuration", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{baseTokenFlag(true), tableRefFlag(true), viewRefFlag(true)}, + DryRun: dryRunViewGetTimebar, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewGetProperty(runtime, "timebar", "timebar") + }, +} diff --git a/shortcuts/base/view_list.go b/shortcuts/base/view_list.go new file mode 100644 index 00000000..30fba37b --- /dev/null +++ b/shortcuts/base/view_list.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewList = common.Shortcut{ + Service: "base", + Command: "+view-list", + Description: "List views in a table", + Risk: "read", + Scopes: []string{"base:view:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "100", Desc: "pagination size"}, + }, + DryRun: dryRunViewList, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewList(runtime) + }, +} diff --git a/shortcuts/base/view_ops.go b/shortcuts/base/view_ops.go new file mode 100644 index 00000000..53211cf1 --- /dev/null +++ b/shortcuts/base/view_ops.go @@ -0,0 +1,256 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "net/url" + + "github.com/larksuite/cli/shortcuts/common" +) + +func dryRunViewBase(runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Set("base_token", runtime.Str("base-token")). + Set("table_id", baseTableID(runtime)). + Set("view_id", runtime.Str("view-id")) +} + +func dryRunViewList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + return dryRunViewBase(runtime). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/views"). + Params(map[string]interface{}{"offset": offset, "limit": limit}) +} + +func dryRunViewGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewBase(runtime). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id") +} + +func dryRunViewCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + api := dryRunViewBase(runtime) + bodyList, err := parseObjectList(runtime.Str("json"), "json") + if err != nil || len(bodyList) == 0 { + return api.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/views") + } + for _, body := range bodyList { + api.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/views").Body(body) + } + return api +} + +func dryRunViewDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewBase(runtime). + DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id") +} + +func dryRunViewGetProperty(runtime *common.RuntimeContext, segment string) *common.DryRunAPI { + return dryRunViewBase(runtime). + GET(fmt.Sprintf("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/%s", url.PathEscape(segment))) +} + +func dryRunViewSetJSONObject(runtime *common.RuntimeContext, segment string) *common.DryRunAPI { + body, _ := parseJSONObject(runtime.Str("json"), "json") + return dryRunViewBase(runtime). + PUT(fmt.Sprintf("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/%s", url.PathEscape(segment))). + Body(body) +} + +func dryRunViewSetWrapped(runtime *common.RuntimeContext, segment string, wrapper string) *common.DryRunAPI { + raw, err := parseJSONValue(runtime.Str("json"), "json") + if err != nil { + raw = nil + } + return dryRunViewBase(runtime). + PUT(fmt.Sprintf("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id/%s", url.PathEscape(segment))). + Body(wrapViewPropertyBody(raw, wrapper)) +} + +func dryRunViewGetFilter(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewGetProperty(runtime, "filter") +} + +func dryRunViewSetFilter(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewSetJSONObject(runtime, "filter") +} + +func dryRunViewGetGroup(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewGetProperty(runtime, "group") +} + +func dryRunViewSetGroup(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewSetWrapped(runtime, "group", "group_config") +} + +func dryRunViewGetSort(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewGetProperty(runtime, "sort") +} + +func dryRunViewSetSort(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewSetWrapped(runtime, "sort", "sort_config") +} + +func dryRunViewGetTimebar(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewGetProperty(runtime, "timebar") +} + +func dryRunViewSetTimebar(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewSetJSONObject(runtime, "timebar") +} + +func dryRunViewGetCard(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewGetProperty(runtime, "card") +} + +func dryRunViewSetCard(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewSetJSONObject(runtime, "card") +} + +func dryRunViewRename(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return dryRunViewBase(runtime). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/views/:view_id"). + Body(map[string]interface{}{"name": runtime.Str("name")}) +} + +func wrapViewPropertyBody(raw interface{}, key string) interface{} { + if items, ok := raw.([]interface{}); ok { + return map[string]interface{}{key: items} + } + return raw +} + +func validateViewCreate(runtime *common.RuntimeContext) error { + return nil +} + +func validateViewJSONObject(runtime *common.RuntimeContext) error { + return nil +} + +func validateViewJSONValue(runtime *common.RuntimeContext) error { + return nil +} + +func executeViewList(runtime *common.RuntimeContext) error { + offset := runtime.Int("offset") + if offset < 0 { + offset = 0 + } + limit := common.ParseIntBounded(runtime, "limit", 1, 200) + views, total, err := listAllViews(runtime, runtime.Str("base-token"), baseTableID(runtime), offset, limit) + if err != nil { + return err + } + if total == 0 { + total = len(views) + } + runtime.Out(map[string]interface{}{"items": simplifyViews(views), "offset": offset, "limit": limit, "count": len(views), "total": total}, nil) + return nil +} + +func executeViewGet(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewRef := runtime.Str("view-id") + data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"view": data}, nil) + return nil +} + +func executeViewCreate(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewItems, err := parseObjectList(runtime.Str("json"), "json") + if err != nil { + return err + } + created := []interface{}{} + for _, body := range viewItems { + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "views"), nil, body) + if err != nil { + return err + } + created = append(created, data) + } + runtime.Out(map[string]interface{}{"views": created}, nil) + return nil +} + +func executeViewDelete(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewRef := runtime.Str("view-id") + _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"deleted": true, "view_id": viewRef, "view_name": viewRef}, nil) + return nil +} + +func executeViewGetProperty(runtime *common.RuntimeContext, segment string, key string) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewRef := runtime.Str("view-id") + data, err := baseV3CallAny(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef, segment), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{key: data}, nil) + return nil +} + +func executeViewSetJSONObject(runtime *common.RuntimeContext, segment string, key string) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewRef := runtime.Str("view-id") + body, err := parseJSONObject(runtime.Str("json"), "json") + if err != nil { + return err + } + data, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef, segment), nil, body) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{key: data}, nil) + return nil +} + +func executeViewSetWrapped(runtime *common.RuntimeContext, segment string, wrapper string, key string) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewRef := runtime.Str("view-id") + raw, err := parseJSONValue(runtime.Str("json"), "json") + if err != nil { + return err + } + payload := wrapViewPropertyBody(raw, wrapper) + data, err := baseV3CallAny(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef, segment), nil, payload) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{key: data}, nil) + return nil +} + +func executeViewRename(runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + tableIDValue := baseTableID(runtime) + viewRef := runtime.Str("view-id") + data, err := baseV3Call(runtime, "PATCH", baseV3Path("bases", baseToken, "tables", tableIDValue, "views", viewRef), nil, map[string]interface{}{"name": runtime.Str("name")}) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"view": data}, nil) + return nil +} diff --git a/shortcuts/base/view_rename.go b/shortcuts/base/view_rename.go new file mode 100644 index 00000000..22ab08a4 --- /dev/null +++ b/shortcuts/base/view_rename.go @@ -0,0 +1,29 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewRename = common.Shortcut{ + Service: "base", + Command: "+view-rename", + Description: "Rename a view by ID or name", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + viewRefFlag(true), + {Name: "name", Desc: "new view name", Required: true}, + }, + DryRun: dryRunViewRename, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewRename(runtime) + }, +} diff --git a/shortcuts/base/view_set_card.go b/shortcuts/base/view_set_card.go new file mode 100644 index 00000000..416c674b --- /dev/null +++ b/shortcuts/base/view_set_card.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewSetCard = common.Shortcut{ + Service: "base", + Command: "+view-set-card", + Description: "Set view card configuration", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + viewRefFlag(true), + {Name: "json", Desc: "card JSON object", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateViewJSONObject(runtime) + }, + DryRun: dryRunViewSetCard, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewSetJSONObject(runtime, "card", "card") + }, +} diff --git a/shortcuts/base/view_set_filter.go b/shortcuts/base/view_set_filter.go new file mode 100644 index 00000000..ff065fb5 --- /dev/null +++ b/shortcuts/base/view_set_filter.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewSetFilter = common.Shortcut{ + Service: "base", + Command: "+view-set-filter", + Description: "Set view filter configuration", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + viewRefFlag(true), + {Name: "json", Desc: "filter JSON object", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateViewJSONObject(runtime) + }, + DryRun: dryRunViewSetFilter, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewSetJSONObject(runtime, "filter", "filter") + }, +} diff --git a/shortcuts/base/view_set_group.go b/shortcuts/base/view_set_group.go new file mode 100644 index 00000000..9a9f6618 --- /dev/null +++ b/shortcuts/base/view_set_group.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewSetGroup = common.Shortcut{ + Service: "base", + Command: "+view-set-group", + Description: "Set view group configuration", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + viewRefFlag(true), + {Name: "json", Desc: "group JSON object/array", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateViewJSONValue(runtime) + }, + DryRun: dryRunViewSetGroup, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewSetWrapped(runtime, "group", "group_config", "group") + }, +} diff --git a/shortcuts/base/view_set_sort.go b/shortcuts/base/view_set_sort.go new file mode 100644 index 00000000..874473e6 --- /dev/null +++ b/shortcuts/base/view_set_sort.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewSetSort = common.Shortcut{ + Service: "base", + Command: "+view-set-sort", + Description: "Set view sort configuration", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + viewRefFlag(true), + {Name: "json", Desc: "sort JSON object/array", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateViewJSONValue(runtime) + }, + DryRun: dryRunViewSetSort, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewSetWrapped(runtime, "sort", "sort_config", "sort") + }, +} diff --git a/shortcuts/base/view_set_timebar.go b/shortcuts/base/view_set_timebar.go new file mode 100644 index 00000000..d9ba352f --- /dev/null +++ b/shortcuts/base/view_set_timebar.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseViewSetTimebar = common.Shortcut{ + Service: "base", + Command: "+view-set-timebar", + Description: "Set view timebar configuration", + Risk: "write", + Scopes: []string{"base:view:write_only"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + viewRefFlag(true), + {Name: "json", Desc: "timebar JSON object", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateViewJSONObject(runtime) + }, + DryRun: dryRunViewSetTimebar, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeViewSetJSONObject(runtime, "timebar", "timebar") + }, +} diff --git a/shortcuts/base/workflow_create.go b/shortcuts/base/workflow_create.go new file mode 100644 index 00000000..0bba5bbe --- /dev/null +++ b/shortcuts/base/workflow_create.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseWorkflowCreate = common.Shortcut{ + Service: "base", + Command: "+workflow-create", + Description: "Create a new workflow in a base", + Risk: "write", + Scopes: []string{"base:workflow:create"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "json", Desc: `workflow body JSON, e.g. {"title":"My Workflow","steps":[...]}; or @path/to/file.json for large definitions`, Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + raw, err := loadJSONInput(runtime.Str("json"), "json") + if err != nil { + return err + } + if _, err := parseJSONObject(raw, "json"); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + var body map[string]interface{} + if raw, err := loadJSONInput(runtime.Str("json"), "json"); err == nil { + body, _ = parseJSONObject(raw, "json") + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/workflows"). + Body(body). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + raw, err := loadJSONInput(runtime.Str("json"), "json") + if err != nil { + return err + } + body, err := parseJSONObject(raw, "json") + if err != nil { + return err + } + data, err := baseV3Call(runtime, "POST", + baseV3Path("bases", runtime.Str("base-token"), "workflows"), + nil, + body, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/workflow_disable.go b/shortcuts/base/workflow_disable.go new file mode 100644 index 00000000..7b3e2c3c --- /dev/null +++ b/shortcuts/base/workflow_disable.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseWorkflowDisable = common.Shortcut{ + Service: "base", + Command: "+workflow-disable", + Description: "Disable a workflow in a base", + Risk: "write", + Scopes: []string{"base:workflow:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("workflow-id")) == "" { + return common.FlagErrorf("--workflow-id must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id/disable"). + Set("base_token", runtime.Str("base-token")). + Set("workflow_id", runtime.Str("workflow-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "PATCH", + baseV3Path("bases", runtime.Str("base-token"), "workflows", runtime.Str("workflow-id"), "disable"), + nil, + map[string]interface{}{}, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/workflow_enable.go b/shortcuts/base/workflow_enable.go new file mode 100644 index 00000000..3bf9e96d --- /dev/null +++ b/shortcuts/base/workflow_enable.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseWorkflowEnable = common.Shortcut{ + Service: "base", + Command: "+workflow-enable", + Description: "Enable a workflow in a base", + Risk: "write", + Scopes: []string{"base:workflow:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("workflow-id")) == "" { + return common.FlagErrorf("--workflow-id must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id/enable"). + Set("base_token", runtime.Str("base-token")). + Set("workflow_id", runtime.Str("workflow-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "PATCH", + baseV3Path("bases", runtime.Str("base-token"), "workflows", runtime.Str("workflow-id"), "enable"), + nil, + map[string]interface{}{}, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/workflow_execute_test.go b/shortcuts/base/workflow_execute_test.go new file mode 100644 index 00000000..be2fd3f8 --- /dev/null +++ b/shortcuts/base/workflow_execute_test.go @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestBaseWorkflowExecuteGet(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_1", "title": "My Workflow"}, + }, + }) + if err := runShortcut(t, BaseWorkflowGet, []string{"+workflow-get", "--base-token", "app_x", "--workflow-id", "wkf_1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"wkf_1"`) || !strings.Contains(got, `"My Workflow"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseWorkflowExecuteGetWithUserIDType(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "user_id_type=open_id", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_1", "creator": map[string]interface{}{"open_id": "ou_abc"}}, + }, + }) + if err := runShortcut(t, BaseWorkflowGet, []string{"+workflow-get", "--base-token", "app_x", "--workflow-id", "wkf_1", "--user-id-type", "open_id"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"ou_abc"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseWorkflowExecuteGetValidate(t *testing.T) { + t.Run("missing base-token", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowGet, []string{"+workflow-get", "--workflow-id", "wkf_1"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "base-token") { + t.Fatalf("err=%v", err) + } + }) + t.Run("missing workflow-id", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowGet, []string{"+workflow-get", "--base-token", "app_x"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "workflow-id") { + t.Fatalf("err=%v", err) + } + }) +} + +func TestBaseWorkflowExecuteCreate(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/workflows", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_new", "title": "My Workflow"}, + }, + }) + if err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `{"title":"My Workflow","steps":[]}`}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"wkf_new"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseWorkflowExecuteCreateValidate(t *testing.T) { + t.Run("missing base-token", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--json", `{"title":"x"}`}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "base-token") { + t.Fatalf("err=%v", err) + } + }) + t.Run("invalid json", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowCreate, []string{"+workflow-create", "--base-token", "app_x", "--json", `not-json`}, factory, stdout) + if err == nil { + t.Fatalf("expected error for invalid json") + } + }) +} + +func TestBaseWorkflowExecuteDisable(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + registerTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/workflows/wkf_1/disable", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"workflow_id": "wkf_1", "status": "disabled"}, + }, + }) + if err := runShortcut(t, BaseWorkflowDisable, []string{"+workflow-disable", "--base-token", "app_x", "--workflow-id", "wkf_1"}, factory, stdout); err != nil { + t.Fatalf("err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"disabled"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestBaseWorkflowExecuteDisableValidate(t *testing.T) { + t.Run("missing base-token", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowDisable, []string{"+workflow-disable", "--workflow-id", "wkf_1"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "base-token") { + t.Fatalf("err=%v", err) + } + }) + t.Run("missing workflow-id", func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseWorkflowDisable, []string{"+workflow-disable", "--base-token", "app_x"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "workflow-id") { + t.Fatalf("err=%v", err) + } + }) +} diff --git a/shortcuts/base/workflow_get.go b/shortcuts/base/workflow_get.go new file mode 100644 index 00000000..2a7517be --- /dev/null +++ b/shortcuts/base/workflow_get.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseWorkflowGet = common.Shortcut{ + Service: "base", + Command: "+workflow-get", + Description: "Get a single workflow definition (including steps) from a base", + Risk: "read", + Scopes: []string{"base:workflow:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, + {Name: "user-id-type", Desc: "user ID type for creator/updater fields", Enum: []string{"open_id", "union_id", "user_id"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("workflow-id")) == "" { + return common.FlagErrorf("--workflow-id must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + api := common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). + Set("base_token", runtime.Str("base-token")). + Set("workflow_id", runtime.Str("workflow-id")) + if t := runtime.Str("user-id-type"); t != "" { + api = api.Params(map[string]interface{}{"user_id_type": t}) + } + return api + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + var params map[string]interface{} + if t := runtime.Str("user-id-type"); t != "" { + params = map[string]interface{}{"user_id_type": t} + } + data, err := baseV3Call(runtime, "GET", + baseV3Path("bases", runtime.Str("base-token"), "workflows", runtime.Str("workflow-id")), + params, + nil, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/base/workflow_list.go b/shortcuts/base/workflow_list.go new file mode 100644 index 00000000..92a40540 --- /dev/null +++ b/shortcuts/base/workflow_list.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseWorkflowList = common.Shortcut{ + Service: "base", + Command: "+workflow-list", + Description: "List all workflows in a base (auto-paginated)", + Risk: "read", + Scopes: []string{"base:workflow:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "status", Desc: "filter by status", Enum: []string{"enabled", "disabled"}}, + {Name: "page-size", Type: "int", Default: "100", Desc: "page size per request (max 100)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body := map[string]interface{}{ + "page_size": runtime.Int("page-size"), + } + if s := runtime.Str("status"); s != "" { + body["status"] = s + } + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/workflows/list"). + Body(body). + Set("base_token", runtime.Str("base-token")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + var allItems []interface{} + pageToken := "" + for { + body := map[string]interface{}{ + "page_size": runtime.Int("page-size"), + } + if pageToken != "" { + body["page_token"] = pageToken + } + if s := runtime.Str("status"); s != "" { + body["status"] = s + } + data, err := baseV3Call(runtime, "POST", + baseV3Path("bases", runtime.Str("base-token"), "workflows", "list"), + nil, + body, + ) + if err != nil { + return err + } + items, _ := data["items"].([]interface{}) + allItems = append(allItems, items...) + hasMore, _ := data["has_more"].(bool) + if !hasMore { + break + } + nextToken, _ := data["page_token"].(string) + if nextToken == "" { + break + } + pageToken = nextToken + } + runtime.Out(map[string]interface{}{ + "items": allItems, + "total": len(allItems), + }, nil) + return nil + }, +} diff --git a/shortcuts/base/workflow_update.go b/shortcuts/base/workflow_update.go new file mode 100644 index 00000000..0b316e4f --- /dev/null +++ b/shortcuts/base/workflow_update.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseWorkflowUpdate = common.Shortcut{ + Service: "base", + Command: "+workflow-update", + Description: "Replace a workflow's full definition (title and/or steps) in a base", + Risk: "write", + Scopes: []string{"base:workflow:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "base-token", Desc: "base token", Required: true}, + {Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true}, + {Name: "json", Desc: `workflow body JSON, e.g. {"title":"New Title","steps":[...]}; or @path/to/file.json for large definitions`, Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("base-token")) == "" { + return common.FlagErrorf("--base-token must not be blank") + } + if strings.TrimSpace(runtime.Str("workflow-id")) == "" { + return common.FlagErrorf("--workflow-id must not be blank") + } + raw, err := loadJSONInput(runtime.Str("json"), "json") + if err != nil { + return err + } + if _, err := parseJSONObject(raw, "json"); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + var body map[string]interface{} + if raw, err := loadJSONInput(runtime.Str("json"), "json"); err == nil { + body, _ = parseJSONObject(raw, "json") + } + return common.NewDryRunAPI(). + PUT("/open-apis/base/v3/bases/:base_token/workflows/:workflow_id"). + Body(body). + Set("base_token", runtime.Str("base-token")). + Set("workflow_id", runtime.Str("workflow-id")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + raw, err := loadJSONInput(runtime.Str("json"), "json") + if err != nil { + return err + } + body, err := parseJSONObject(raw, "json") + if err != nil { + return err + } + data, err := baseV3Call(runtime, "PUT", + baseV3Path("bases", runtime.Str("base-token"), "workflows", runtime.Str("workflow-id")), + nil, + body, + ) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_agenda.go b/shortcuts/calendar/calendar_agenda.go new file mode 100644 index 00000000..70093c83 --- /dev/null +++ b/shortcuts/calendar/calendar_agenda.go @@ -0,0 +1,294 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "fmt" + "io" + "sort" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const maxInstanceViewSpanSeconds = 40 * 24 * 60 * 60 +const minSplitWindowSeconds = 2 * 60 * 60 + +// Calendar API error codes. +const ( + larkErrCalendarTimeRangeExceeded = 193103 // instance_view query time range exceeds 40-day limit + larkErrCalendarTooManyInstances = 193104 // instance_view returns more than 1000 instances +) + +func fetchInstanceViewRange(ctx context.Context, runtime *common.RuntimeContext, calendarId string, startTime, endTime int64, depth int) ([]map[string]interface{}, error) { + if depth > 10 { + return nil, output.Errorf(output.ExitInternal, "recursion_limit", "too many splits for instance_view") + } + if startTime > endTime { + return nil, nil + } + span := endTime - startTime + if span > maxInstanceViewSpanSeconds { + mid := startTime + span/2 + left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1) + if err != nil { + return nil, err + } + right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1) + if err != nil { + return nil, err + } + return append(left, right...), nil + } + + result, err := runtime.RawAPI("GET", + fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/instance_view", validate.EncodePathSegment(calendarId)), + map[string]interface{}{ + "start_time": fmt.Sprintf("%d", startTime), + "end_time": fmt.Sprintf("%d", endTime), + }, nil) + if err != nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", err) + } + + resultMap, _ := result.(map[string]interface{}) + code, _ := util.ToFloat64(resultMap["code"]) + + if code == 0 { + data, _ := resultMap["data"].(map[string]interface{}) + items, _ := data["items"].([]interface{}) + var events []map[string]interface{} + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + events = append(events, m) + } + } + return events, nil + } + + // Error 193103: time range exceeds limit -> split + if int(code) == larkErrCalendarTimeRangeExceeded { + mid := startTime + span/2 + if mid <= startTime { + return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: time range exceeds 40-day limit, please narrow the range") + } + left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1) + if err != nil { + return nil, err + } + right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1) + if err != nil { + return nil, err + } + return append(left, right...), nil + } + + // Error 193104: too many instances -> split + if int(code) == larkErrCalendarTooManyInstances { + if span <= minSplitWindowSeconds { + return nil, output.Errorf(output.ExitAPI, "api_error", "query failed: more than 1000 instances in the time range, please narrow the range") + } + mid := startTime + span/2 + left, err := fetchInstanceViewRange(ctx, runtime, calendarId, startTime, mid, depth+1) + if err != nil { + return nil, err + } + right, err := fetchInstanceViewRange(ctx, runtime, calendarId, mid+1, endTime, depth+1) + if err != nil { + return nil, err + } + return append(left, right...), nil + } + + msg, _ := resultMap["msg"].(string) + return nil, output.ErrAPI(int(code), msg, resultMap["error"]) +} + +func dedupeAndSortItems(items []map[string]interface{}) []map[string]interface{} { + seen := make(map[string]bool) + var result []map[string]interface{} + for _, e := range items { + eventId, _ := e["event_id"].(string) + startMap, _ := e["start_time"].(map[string]interface{}) + endMap, _ := e["end_time"].(map[string]interface{}) + startTs, _ := startMap["timestamp"].(string) + endTs, _ := endMap["timestamp"].(string) + key := eventId + "|" + startTs + "|" + endTs + if !seen[key] { + seen[key] = true + result = append(result, e) + } + } + + sort.Slice(result, func(i, j int) bool { + si, _ := result[i]["start_time"].(map[string]interface{}) + sj, _ := result[j]["start_time"].(map[string]interface{}) + ti, _ := si["timestamp"].(string) + tj, _ := sj["timestamp"].(string) + ni, _ := strconv.ParseInt(ti, 10, 64) + nj, _ := strconv.ParseInt(tj, 10, 64) + return ni < nj + }) + + return result +} + +// parseTimeRange parses --start/--end into Unix seconds. +func parseTimeRange(runtime *common.RuntimeContext) (int64, int64, error) { + startInput, endInput := resolveStartEnd(runtime) + + startTime, err := common.ParseTime(startInput) + if err != nil { + return 0, 0, output.ErrValidation("--start: %v", err) + } + endTime, err := common.ParseTime(endInput, "end") + if err != nil { + return 0, 0, output.ErrValidation("--end: %v", err) + } + + startInt, err := strconv.ParseInt(startTime, 10, 64) + if err != nil { + return 0, 0, output.ErrValidation("invalid start time: %v", err) + } + endInt, err := strconv.ParseInt(endTime, 10, 64) + if err != nil { + return 0, 0, output.ErrValidation("invalid end time: %v", err) + } + + return startInt, endInt, nil +} + +var CalendarAgenda = common.Shortcut{ + Service: "calendar", + Command: "+agenda", + Description: "View calendar agenda (defaults to today)", + Risk: "read", + Scopes: []string{"calendar:calendar.event:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "start", Desc: "start time (ISO 8601, default: start of today)"}, + {Name: "end", Desc: "end time (ISO 8601, default: end of start day)"}, + {Name: "calendar-id", Desc: "calendar ID (default: primary)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + startInt, endInt, err := parseTimeRange(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + calendarId := runtime.Str("calendar-id") + d := common.NewDryRunAPI() + switch calendarId { + case "": + d.Desc("(calendar-id omitted) Will use primary calendar") + calendarId = "" + case "primary": + calendarId = "" + } + return d. + GET("/open-apis/calendar/v4/calendars/:calendar_id/events/instance_view"). + Params(map[string]interface{}{"start_time": fmt.Sprintf("%d", startInt), "end_time": fmt.Sprintf("%d", endInt)}). + Set("calendar_id", calendarId) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + startInt, endInt, err := parseTimeRange(runtime) + if err != nil { + return err + } + calendarId := strings.TrimSpace(runtime.Str("calendar-id")) + if calendarId == "" { + calendarId = PrimaryCalendarIDStr + } + + items, err := fetchInstanceViewRange(ctx, runtime, calendarId, startInt, endInt, 0) + if err != nil { + return err + } + visible := dedupeAndSortItems(items) + + // Filter cancelled + filtered := make([]map[string]interface{}, 0) + for _, e := range visible { + status, _ := e["status"].(string) + if status != "cancelled" { + delete(e, "status") + delete(e, "attendees") + + // Replace timestamp with datetime (RFC3339, device timezone) + if startMap, ok := e["start_time"].(map[string]interface{}); ok { + if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" { + if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { + startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339) + delete(startMap, "timestamp") + } + } + } + if endMap, ok := e["end_time"].(map[string]interface{}); ok { + if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" { + if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { + endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339) + delete(endMap, "timestamp") + } + } + // If datetime is empty (all-day event), adjust date: date -> timestamp(00:00:00 UTC) -> -1s -> date + if dt, _ := endMap["datetime"].(string); dt == "" { + if dateStr, ok := endMap["date"].(string); ok && dateStr != "" { + if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil { + endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02") + } + } + } + } + + filtered = append(filtered, e) + } + } + + runtime.OutFormat(filtered, &output.Meta{Count: len(filtered)}, func(w io.Writer) { + if len(filtered) == 0 { + fmt.Fprintln(w, "No events in this time range.") + return + } + + var rows []map[string]interface{} + for _, e := range filtered { + summary, _ := e["summary"].(string) + if summary == "" { + summary = "(untitled)" + } + summary = common.TruncateStr(summary, 40) + startMap, _ := e["start_time"].(map[string]interface{}) + endMap, _ := e["end_time"].(map[string]interface{}) + startStr, _ := startMap["datetime"].(string) + if startStr == "" { + startStr, _ = startMap["date"].(string) + } + endStr, _ := endMap["datetime"].(string) + if endStr == "" { + endStr, _ = endMap["date"].(string) + } + freeBusyStatus, _ := e["free_busy_status"].(string) + selfRsvpStatus, _ := e["self_rsvp_status"].(string) + eventId, _ := e["event_id"].(string) + rows = append(rows, map[string]interface{}{ + "event_id": eventId, + "summary": summary, + "start": startStr, + "end": endStr, + "free_busy_status": freeBusyStatus, + "self_rsvp_status": selfRsvpStatus, + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d event(s) total\n", len(filtered)) + }) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_create.go b/shortcuts/calendar/calendar_create.go new file mode 100644 index 00000000..329e9d52 --- /dev/null +++ b/shortcuts/calendar/calendar_create.go @@ -0,0 +1,283 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} { + eventData := map[string]interface{}{ + "summary": runtime.Str("summary"), + "description": runtime.Str("description"), + "start_time": map[string]string{"timestamp": startTs}, + "end_time": map[string]string{"timestamp": endTs}, + "attendee_ability": "can_modify_event", + "free_busy_status": "busy", + "reminders": []map[string]int{ + {"minutes": 5}, + }, + } + if rrule := runtime.Str("rrule"); rrule != "" { + eventData["recurrence"] = rrule + } + return eventData +} + +func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]string, error) { + if attendeesStr == "" && currentUserId == "" { + return nil, nil + } + ids := strings.Split(attendeesStr, ",") + uniqueIds := make(map[string]bool) + if currentUserId != "" { + uniqueIds[currentUserId] = true + } + for _, id := range ids { + id = strings.TrimSpace(id) + if id != "" { + uniqueIds[id] = true + } + } + var attendees []map[string]string + for id := range uniqueIds { + switch { + case strings.HasPrefix(id, "oc_"): + attendees = append(attendees, map[string]string{"type": "chat", "chat_id": id}) + case strings.HasPrefix(id, "omm_"): + attendees = append(attendees, map[string]string{"type": "resource", "room_id": id}) + case strings.HasPrefix(id, "ou_"): + attendees = append(attendees, map[string]string{"type": "user", "user_id": id}) + default: + return nil, fmt.Errorf("unsupported attendee id format: %s", id) + } + } + return attendees, nil +} + +var CalendarCreate = common.Shortcut{ + Service: "calendar", + Command: "+create", + Description: "Create a calendar event and optionally invite attendees", + Risk: "write", + Scopes: []string{"calendar:calendar.event:create", "calendar:calendar.event:update"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "summary", Desc: "event title"}, + {Name: "start", Desc: "start time (ISO 8601)", Required: true}, + {Name: "end", Desc: "end time (ISO 8601)", Required: true}, + {Name: "description", Desc: "event description"}, + {Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"}, + {Name: "calendar-id", Desc: "calendar ID (default: primary)"}, + {Name: "rrule", Desc: "recurrence rule (rfc5545)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + for _, flag := range []string{"summary", "description", "rrule", "calendar-id"} { + if val := runtime.Str(flag); val != "" { + if err := common.RejectDangerousChars("--"+flag, val); err != nil { + return output.ErrValidation(err.Error()) + } + } + } + + if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" { + for _, id := range strings.Split(attendeesStr, ",") { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") && !strings.HasPrefix(id, "omm_") { + return output.ErrValidation("invalid attendee id format %q: should start with 'ou_', 'oc_', or 'omm_'", id) + } + } + } + + if runtime.Str("start") == "" { + return common.FlagErrorf("specify --start (e.g. '2026-03-12T14:00+08:00')") + } + if runtime.Str("end") == "" { + return common.FlagErrorf("specify --end (e.g. '2026-03-12T15:00+08:00')") + } + startTs, err := common.ParseTime(runtime.Str("start")) + if err != nil { + return common.FlagErrorf("--start: %v", err) + } + endTs, err := common.ParseTime(runtime.Str("end"), "end") + if err != nil { + return common.FlagErrorf("--end: %v", err) + } + s, err := strconv.ParseInt(startTs, 10, 64) + if err != nil { + return common.FlagErrorf("invalid start time: %v", err) + } + e, err := strconv.ParseInt(endTs, 10, 64) + if err != nil { + return common.FlagErrorf("invalid end time: %v", err) + } + if e <= s { + return common.FlagErrorf("end time must be after start time") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + calendarId := runtime.Str("calendar-id") + d := common.NewDryRunAPI() + switch calendarId { + case "": + d.Desc("(calendar-id omitted) Will use primary calendar") + calendarId = "" + case "primary": + calendarId = "" + } + startTs, err := common.ParseTime(runtime.Str("start")) + if err != nil { + return common.NewDryRunAPI().Set("error", fmt.Sprintf("--start: %v", err)) + } + endTs, err := common.ParseTime(runtime.Str("end"), "end") + if err != nil { + return common.NewDryRunAPI().Set("error", fmt.Sprintf("--end: %v", err)) + } + eventData := buildEventData(runtime, startTs, endTs) + attendeesStr := runtime.Str("attendee-ids") + if attendeesStr != "" { + // Note: dry-run doesn't network resolve the current user's open_id. + attendees, err := parseAttendees(attendeesStr, "") + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + + d.Desc("2-step: create event → add attendees (auto-rollback on failure)"). + POST("/open-apis/calendar/v4/calendars/:calendar_id/events"). + Desc("[1/2] Create event"). + Body(eventData). + POST("/open-apis/calendar/v4/calendars/:calendar_id/events//attendees"). + Desc("[2/2] Add attendees (on failure: auto-delete event)"). + Params(map[string]interface{}{"user_id_type": "open_id"}). + Body(map[string]interface{}{"attendees": attendees, "need_notification": true}) + } else { + d.POST("/open-apis/calendar/v4/calendars/:calendar_id/events"). + Body(eventData) + } + return d.Set("calendar_id", calendarId) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + calendarId := strings.TrimSpace(runtime.Str("calendar-id")) + if calendarId == "" { + calendarId = PrimaryCalendarIDStr + } + + startTs, err := common.ParseTime(runtime.Str("start")) + if err != nil { + return output.ErrValidation("--start: %v", err) + } + endTs, err := common.ParseTime(runtime.Str("end"), "end") + if err != nil { + return output.ErrValidation("--end: %v", err) + } + + eventData := buildEventData(runtime, startTs, endTs) + + // Create event + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events", validate.EncodePathSegment(calendarId)), + nil, eventData) + if err != nil { + return err + } + event, _ := data["event"].(map[string]interface{}) + eventId, _ := event["event_id"].(string) + if eventId == "" { + return output.Errorf(output.ExitAPI, "api_error", "failed to create event: no event_id returned") + } + + // Add attendees if specified + if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" { + currentUserId := "" + if !runtime.IsBot() { + currentUserId = runtime.UserOpenId() + } + attendees, err := parseAttendees(attendeesStr, currentUserId) + if err != nil { + return output.ErrValidation("invalid attendee id: %v", err) + } + + _, err = runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s/attendees", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)), + map[string]interface{}{"user_id_type": "open_id"}, + map[string]interface{}{ + "attendees": attendees, + "need_notification": true, + }) + if err != nil { + // Rollback: delete the event + _, rollbackErr := runtime.RawAPI("DELETE", + fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)), + map[string]interface{}{"need_notification": false}, nil) + if rollbackErr != nil { + return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; rollback also failed, orphan event_id=%s needs manual cleanup", rollbackErr, eventId) + } + return output.Errorf(output.ExitAPI, "api_error", "failed to add attendees: %v; event rolled back successfully", err) + } + } + + startMap, _ := event["start_time"].(map[string]interface{}) + endMap, _ := event["end_time"].(map[string]interface{}) + + // Replace timestamp with datetime (RFC3339, device timezone) + if startMap != nil { + if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" { + if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { + startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339) + delete(startMap, "timestamp") + } + } + } + if endMap != nil { + if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" { + if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { + endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339) + delete(endMap, "timestamp") + } + } + // If datetime is empty (all-day event), adjust date: date -> timestamp(00:00:00 UTC) -> -1s -> date + if dt, _ := endMap["datetime"].(string); dt == "" { + if dateStr, ok := endMap["date"].(string); ok && dateStr != "" { + if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil { + endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02") + } + } + } + } + + var startStr, endStr string + if startMap != nil { + startStr, _ = startMap["datetime"].(string) + if startStr == "" { + startStr, _ = startMap["date"].(string) + } + } + if endMap != nil { + endStr, _ = endMap["datetime"].(string) + if endStr == "" { + endStr, _ = endMap["date"].(string) + } + } + + runtime.Out(map[string]interface{}{ + "event_id": eventId, + "summary": event["summary"], + "start": startStr, + "end": endStr, + }, nil) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_freebusy.go b/shortcuts/calendar/calendar_freebusy.go new file mode 100644 index 00000000..f3b9dc9a --- /dev/null +++ b/shortcuts/calendar/calendar_freebusy.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "fmt" + "io" + "strconv" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// parseFreebusyTimeRange parses --start/--end into RFC3339. +func parseFreebusyTimeRange(runtime *common.RuntimeContext) (string, string, error) { + startInput, endInput := resolveStartEnd(runtime) + + startTs, err := common.ParseTime(startInput) + if err != nil { + return "", "", output.ErrValidation("--start: %v", err) + } + endTs, err := common.ParseTime(endInput, "end") + if err != nil { + return "", "", output.ErrValidation("--end: %v", err) + } + + startSec, err := strconv.ParseInt(startTs, 10, 64) + if err != nil { + return "", "", output.ErrValidation("invalid start timestamp: %v", err) + } + endSec, err := strconv.ParseInt(endTs, 10, 64) + if err != nil { + return "", "", output.ErrValidation("invalid end timestamp: %v", err) + } + + timeMin := time.Unix(startSec, 0).Format(time.RFC3339) + timeMax := time.Unix(endSec, 0).Format(time.RFC3339) + return timeMin, timeMax, nil +} + +var CalendarFreebusy = common.Shortcut{ + Service: "calendar", + Command: "+freebusy", + Description: "Query user free/busy and RSVP status", + Risk: "read", + Scopes: []string{"calendar:calendar.free_busy:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "start", Desc: "start time (ISO 8601, default: today)"}, + {Name: "end", Desc: "end time (ISO 8601, default: end of start day)"}, + {Name: "user-id", Desc: "target user open_id (ou_ prefix, default: current user)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + userId := runtime.Str("user-id") + if userId == "" { + userId = runtime.UserOpenId() + } + timeMin, timeMax, err := parseFreebusyTimeRange(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + POST("/open-apis/calendar/v4/freebusy/list"). + Body(map[string]interface{}{"time_min": timeMin, "time_max": timeMax, "user_id": userId, "need_rsvp_status": true}) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + userId := runtime.Str("user-id") + if userId == "" && runtime.IsBot() { + return common.FlagErrorf("--user-id is required for bot identity") + } + if userId == "" && runtime.UserOpenId() == "" { + return common.FlagErrorf("cannot determine user ID, specify --user-id or ensure you are logged in") + } + if userId != "" { + if _, err := common.ValidateUserID(userId); err != nil { + return err + } + } + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + userId := runtime.Str("user-id") + if userId == "" { + userId = runtime.UserOpenId() + } + + timeMin, timeMax, err := parseFreebusyTimeRange(runtime) + if err != nil { + return output.ErrValidation("--start/--end: %v", err) + } + + data, err := runtime.CallAPI("POST", "/open-apis/calendar/v4/freebusy/list", nil, map[string]interface{}{ + "time_min": timeMin, + "time_max": timeMax, + "user_id": userId, + "need_rsvp_status": true, + }) + if err != nil { + return err + } + items, _ := data["freebusy_list"].([]interface{}) + + runtime.OutFormat(items, &output.Meta{Count: len(items)}, func(w io.Writer) { + if len(items) == 0 { + fmt.Fprintln(w, "No busy periods in this time range.") + return + } + + var rows []map[string]interface{} + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, map[string]interface{}{ + "start": m["start_time"], + "end": m["end_time"], + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d busy period(s) total\n", len(items)) + }) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_suggestion.go b/shortcuts/calendar/calendar_suggestion.go new file mode 100644 index 00000000..8d151529 --- /dev/null +++ b/shortcuts/calendar/calendar_suggestion.go @@ -0,0 +1,337 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + suggestionPath = "/open-apis/calendar/v4/freebusy/suggestion" + + flagStart = "start" + flagEnd = "end" + flagAttendees = "attendee-ids" + flagEventRrule = "event-rrule" + flagDurationMinutes = "duration-minutes" + flagTimezone = "timezone" + flagExclude = "exclude" +) + +type OpenAPIResponse[T any] struct { + Code int `json:"code,omitempty"` + Msg string `json:"msg,omitempty"` + Data T `json:"data,omitempty"` +} + +type SuggestionRequest struct { + SearchStartTime string `json:"search_start_time,omitempty"` + SearchEndTime string `json:"search_end_time,omitempty"` + Timezone string `json:"timezone,omitempty"` + EventRrule string `json:"event_rrule,omitempty"` + DurationMinutes int `json:"duration_minutes,omitempty"` + AttendeeUserIds []string `json:"attendee_user_ids,omitempty"` + AttendeeChatIds []string `json:"attendee_chat_ids,omitempty"` + ExcludedEventTimes []*EventTime `json:"excluded_event_times,omitempty"` +} + +type EventTime struct { + EventStartTime string `json:"event_start_time,omitempty"` + EventEndTime string `json:"event_end_time,omitempty"` + RecommendReason string `json:"recommend_reason,omitempty"` +} + +type SuggestionResponse struct { + Suggestions []*EventTime `json:"suggestions,omitempty"` + AiActionGuidance string `json:"ai_action_guidance,omitempty"` +} + +func buildSuggestionRequest(runtime *common.RuntimeContext) (*SuggestionRequest, error) { + req := &SuggestionRequest{} + + // resolve start and end times specifically for suggestion (default to current time to end of today) + startInput := runtime.Str(flagStart) + if startInput == "" { + startInput = time.Now().Format(time.RFC3339) + } + + timeMin, err := common.ParseTime(startInput) + if err != nil { + return nil, output.ErrValidation("invalid --start: %v", err) + } + minSec, err := strconv.ParseInt(timeMin, 10, 64) + if err != nil { + return nil, output.ErrValidation("invalid start timestamp: %v", err) + } + startTime := time.Unix(minSec, 0) + + endInput := runtime.Str(flagEnd) + if endInput == "" { + // end of start time's day + endOfStartDay := time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 23, 59, 59, 0, startTime.Location()) + endInput = endOfStartDay.Format(time.RFC3339) + } + + timeMax, err := common.ParseTime(endInput, "end") + if err != nil { + return nil, output.ErrValidation("invalid --end: %v", err) + } + // Convert Unix timestamp string back to RFC3339 since the API requires RFC3339 + maxSec, err := strconv.ParseInt(timeMax, 10, 64) + if err != nil { + return nil, output.ErrValidation("invalid end timestamp: %v", err) + } + req.SearchStartTime = startTime.Format(time.RFC3339) + req.SearchEndTime = time.Unix(maxSec, 0).Format(time.RFC3339) + + // Parse combined attendees (auto-split by prefix oc_ for chats) + attendeesStr := runtime.Str(flagAttendees) + if attendeesStr != "" { + parts := strings.Split(attendeesStr, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if strings.HasPrefix(p, "oc_") { + req.AttendeeChatIds = append(req.AttendeeChatIds, p) + } else { + req.AttendeeUserIds = append(req.AttendeeUserIds, p) + } + } + } + + // Fallback joining strategy for current user + if !runtime.IsBot() { + userOpenId := runtime.UserOpenId() + found := false + for _, id := range req.AttendeeUserIds { + if id == userOpenId { + found = true + break + } + } + if !found && userOpenId != "" { + req.AttendeeUserIds = append(req.AttendeeUserIds, userOpenId) + } + } + + eventRrule := runtime.Str(flagEventRrule) + if eventRrule != "" { + req.EventRrule = eventRrule + } + + durationMinutes := runtime.Int(flagDurationMinutes) + if durationMinutes > 0 { + req.DurationMinutes = durationMinutes + } + + timezone := runtime.Str(flagTimezone) + if timezone != "" { + req.Timezone = timezone + } + + excludeStr := runtime.Str(flagExclude) + if excludeStr != "" { + excludeStr = strings.TrimSpace(excludeStr) + var excludedTimes []*EventTime + + ranges := strings.Split(excludeStr, ",") + for _, r := range ranges { + r = strings.TrimSpace(r) + if r == "" { + continue + } + parts := strings.Split(r, "~") + if len(parts) != 2 { + return nil, output.ErrValidation("invalid --exclude format %q, expected 'start~end'", r) + } + startTsStr, err := common.ParseTime(parts[0]) + if err != nil { + return nil, output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err) + } + endTsStr, err := common.ParseTime(parts[1], "end") + if err != nil { + return nil, output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err) + } + startSec, err := strconv.ParseInt(startTsStr, 10, 64) + if err != nil { + return nil, output.ErrValidation("invalid start timestamp in --exclude: %v", err) + } + endSec, err := strconv.ParseInt(endTsStr, 10, 64) + if err != nil { + return nil, output.ErrValidation("invalid end timestamp in --exclude: %v", err) + } + excludedTimes = append(excludedTimes, &EventTime{ + EventStartTime: time.Unix(startSec, 0).Format(time.RFC3339), + EventEndTime: time.Unix(endSec, 0).Format(time.RFC3339), + }) + } + + req.ExcludedEventTimes = excludedTimes + } + + return req, nil +} + +var CalendarSuggestion = common.Shortcut{ + Service: "calendar", + Command: "+suggestion", + Description: "Intelligently suggest available meeting times to simplify scheduling", + Risk: "read", + Scopes: []string{"calendar:calendar.free_busy:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: flagStart, Type: "string", Desc: "search start time (ISO 8601, default: current time)"}, + {Name: flagEnd, Type: "string", Desc: "search end time (ISO 8601, default: end of start day)"}, + {Name: flagAttendees, Type: "string", Desc: "attendee IDs, comma-separated (supports user (open_id) ou_xxx, or chat oc_xxx) ids"}, + {Name: flagEventRrule, Type: "string", Desc: "event recurrence rules"}, + {Name: flagDurationMinutes, Type: "int", Desc: "duration (minutes)"}, + {Name: flagTimezone, Type: "string", Desc: "current time zone"}, + {Name: flagExclude, Type: "string", Desc: "excluded event times (ISO 8601, e.g. '2026-03-19T10:00:00+08:00~2026-03-19T11:00:00+08:00'), comma-separated"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + req, err := buildSuggestionRequest(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + POST(suggestionPath). + Body(req) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + durationMinutes := runtime.Int(flagDurationMinutes) + if durationMinutes != 0 && (durationMinutes < 1 || durationMinutes > 1440) { + return output.ErrValidation("--duration-minutes must be between 1 and 1440") + } + + for _, flag := range []string{flagEventRrule, flagTimezone} { + if val := runtime.Str(flag); val != "" { + if err := common.RejectDangerousChars("--"+flag, val); err != nil { + return output.ErrValidation(err.Error()) + } + } + } + + if attendeesStr := runtime.Str(flagAttendees); attendeesStr != "" { + for _, id := range strings.Split(attendeesStr, ",") { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if !strings.HasPrefix(id, "ou_") && !strings.HasPrefix(id, "oc_") { + return output.ErrValidation("invalid attendee id format %q: should start with 'ou_' or 'oc_'", id) + } + } + } + + startInput := runtime.Str(flagStart) + if startInput != "" { + if _, err := common.ParseTime(startInput); err != nil { + return output.ErrValidation("invalid start time: %v", err) + } + } + + endInput := runtime.Str(flagEnd) + if endInput != "" { + if _, err := common.ParseTime(endInput, "end"); err != nil { + return output.ErrValidation("invalid end time: %v", err) + } + } + + excludeStr := runtime.Str(flagExclude) + if excludeStr != "" { + excludeStr = strings.TrimSpace(excludeStr) + ranges := strings.Split(excludeStr, ",") + for _, r := range ranges { + r = strings.TrimSpace(r) + if r == "" { + continue + } + parts := strings.Split(r, "~") + if len(parts) != 2 { + return output.ErrValidation("invalid range format in --exclude: %q, expect start~end", r) + } + if _, err := common.ParseTime(parts[0]); err != nil { + return output.ErrValidation("invalid start time in --exclude: %q (%v)", parts[0], err) + } + if _, err := common.ParseTime(parts[1], "end"); err != nil { + return output.ErrValidation("invalid end time in --exclude: %q (%v)", parts[1], err) + } + } + } + + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + req, err := buildSuggestionRequest(runtime) + if err != nil { + return err + } + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: "POST", + ApiPath: suggestionPath, + Body: req, + }) + if err != nil { + return output.ErrWithHint(output.ExitInternal, "request_fail", "api request fail", err.Error()) + } + + if apiResp.StatusCode < http.StatusOK || apiResp.StatusCode >= http.StatusMultipleChoices { + return output.ErrAPI(apiResp.StatusCode, "", string(apiResp.RawBody)) + } + + var resp = &OpenAPIResponse[*SuggestionResponse]{} + if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil { + return output.ErrWithHint(output.ExitInternal, "validation", "unmarshal response fail", err.Error()) + } + + if resp.Code != 0 { + return output.ErrAPI(resp.Code, resp.Msg, resp.Data) + } + + data := resp.Data + var suggestions []*EventTime + var aiGuidance string + if data != nil { + suggestions = data.Suggestions + aiGuidance = data.AiActionGuidance + } + runtime.OutFormat(data, &output.Meta{Count: len(suggestions)}, func(w io.Writer) { + if len(suggestions) == 0 { + fmt.Fprintln(w, "No suggestions available.") + } else { + var rows []map[string]interface{} + for _, item := range suggestions { + rows = append(rows, map[string]interface{}{ + "start": item.EventStartTime, + "end": item.EventEndTime, + "reason": item.RecommendReason, + }) + } + output.PrintTable(w, rows) + fmt.Fprintf(w, "\n%d suggestion(s) found\n", len(suggestions)) + } + + if aiGuidance != "" { + fmt.Fprintf(w, "\nAction Guidance: %s\n", aiGuidance) + } + }) + return nil + }, +} diff --git a/shortcuts/calendar/calendar_test.go b/shortcuts/calendar/calendar_test.go new file mode 100644 index 00000000..00823f56 --- /dev/null +++ b/shortcuts/calendar/calendar_test.go @@ -0,0 +1,893 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +// warmOnce ensures the Lark SDK's internal token cache is populated exactly +// once per test binary. The SDK caches tenant tokens by app credentials, so +// only the very first API call in the process actually hits the token endpoint. +var warmOnce sync.Once + +func warmTokenCache(t *testing.T) { + t.Helper() + warmOnce.Do(func() { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/v1/warm", + Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, + }) + s := common.Shortcut{ + Service: "test", + Command: "+warm", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + _, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil) + return err + }, + } + parent := &cobra.Command{Use: "test"} + s.Mount(parent, f) + parent.SetArgs([]string{"+warm"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + parent.Execute() + }) +} + +func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + warmTokenCache(t) + parent := &cobra.Command{Use: "test"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func defaultConfig() *core.CliConfig { + return &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + UserOpenId: "ou_testuser", + } +} + +// --------------------------------------------------------------------------- +// CalendarCreate tests +// --------------------------------------------------------------------------- + +func TestCreate_CreateEventOnly(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_001", + "summary": "Test Meeting", + "start_time": map[string]interface{}{ + "timestamp": "1742515200", + }, + "end_time": map[string]interface{}{ + "timestamp": "1742518800", + }, + }, + }, + }, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Test Meeting", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "evt_001") { + t.Errorf("stdout should contain event_id, got: %s", stdout.String()) + } +} + +func TestCreate_WithAttendees_Success(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_002", + "summary": "Team Sync", + "start_time": map[string]interface{}{ + "timestamp": "1742515200", + }, + "end_time": map[string]interface{}{ + "timestamp": "1742518800", + }, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/events/evt_002/attendees", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Team Sync", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--attendee-ids", "ou_user1,ou_user2,oc_group1", + "--as", "bot", + }, f, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreate_WithAttendees_APIError_RollsBack(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_003", + "summary": "Bad Attendees", + "start_time": map[string]interface{}{ + "timestamp": "1742515200", + }, + "end_time": map[string]interface{}{ + "timestamp": "1742518800", + }, + }, + }, + }, + }) + // Attendees API returns business error + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/events/evt_003/attendees", + Body: map[string]interface{}{ + "code": 190002, + "msg": "invalid user_id", + }, + }) + // Rollback: delete the event + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/events/evt_003", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Bad Attendees", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--attendee-ids", "ou_invalid", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected error for invalid attendees, got nil") + } + if !strings.Contains(err.Error(), "rolled back successfully") && !strings.Contains(err.Error(), "auto-rolled back") { + t.Fatalf("error should mention rollback, got: %v", err) + } +} + +func TestCreate_CreateEvent_APIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events", + Body: map[string]interface{}{ + "code": 190001, + "msg": "permission denied", + }, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Denied", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected error for API failure, got nil") + } +} + +func TestCreate_EndBeforeStart(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Invalid", + "--start", "2025-03-21T10:00:00+08:00", + "--end", "2025-03-21T09:00:00+08:00", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected validation error for end < start, got nil") + } + if !strings.Contains(err.Error(), "end time must be after start time") { + t.Errorf("error should mention end/start, got: %v", err) + } +} + +func TestCreate_ExplicitCalendarId(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_explicit/events", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "event": map[string]interface{}{ + "event_id": "evt_004", + "summary": "Explicit Cal", + "start_time": map[string]interface{}{"timestamp": "1742515200"}, + "end_time": map[string]interface{}{"timestamp": "1742518800"}, + }, + }, + }, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "Explicit Cal", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_explicit", + "--as", "bot", + }, f, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreate_NoEventIdReturned(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/calendars/cal_test123/events", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "event": map[string]interface{}{}, + }, + }, + }) + + err := mountAndRun(t, CalendarCreate, []string{ + "+create", + "--summary", "No ID", + "--start", "2025-03-21T00:00:00+08:00", + "--end", "2025-03-21T01:00:00+08:00", + "--calendar-id", "cal_test123", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected error when no event_id returned, got nil") + } +} + +// --------------------------------------------------------------------------- +// CalendarAgenda tests +// --------------------------------------------------------------------------- + +func TestAgenda_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/events/instance_view", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "event_id": "evt_a1", + "summary": "Morning standup", + "status": "confirmed", + "start_time": map[string]interface{}{ + "timestamp": "1742515200", + }, + "end_time": map[string]interface{}{ + "timestamp": "1742518800", + }, + }, + map[string]interface{}{ + "event_id": "evt_a2", + "summary": "All Day Event", + "status": "confirmed", + "start_time": map[string]interface{}{ + "date": "2025-03-21", + }, + "end_time": map[string]interface{}{ + "date": "2025-03-21", + }, + }, + }, + }, + }, + }) + + err := mountAndRun(t, CalendarAgenda, []string{ + "+agenda", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--format", "prettry", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "evt_a1") { + t.Errorf("stdout should contain event_id, got: %s", stdout.String()) + } +} + +func TestAgenda_EmptyResult(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/events/instance_view", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{}, + }, + }, + }) + + err := mountAndRun(t, CalendarAgenda, []string{ + "+agenda", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var envelope map[string]interface{} + if json.Unmarshal(stdout.Bytes(), &envelope) == nil { + if data, ok := envelope["data"].([]interface{}); ok && len(data) != 0 { + t.Errorf("expected empty data array, got %d items", len(data)) + } + } +} + +func TestAgenda_FiltersCancelledEvents(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/events/instance_view", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "event_id": "evt_confirmed", + "summary": "Active Event", + "status": "confirmed", + "start_time": map[string]interface{}{"timestamp": "1742515200"}, + "end_time": map[string]interface{}{"timestamp": "1742518800"}, + }, + map[string]interface{}{ + "event_id": "evt_cancelled", + "summary": "Cancelled Event", + "status": "cancelled", + "start_time": map[string]interface{}{"timestamp": "1742519000"}, + "end_time": map[string]interface{}{"timestamp": "1742522600"}, + }, + }, + }, + }, + }) + + err := mountAndRun(t, CalendarAgenda, []string{ + "+agenda", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "evt_confirmed") { + t.Errorf("stdout should contain confirmed event, got: %s", out) + } + if strings.Contains(out, "evt_cancelled") { + t.Errorf("stdout should not contain cancelled event, got: %s", out) + } +} + +func TestAgenda_ExplicitCalendarId(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/calendar/v4/calendars/cal_my/events/instance_view", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{}, + }, + }, + }) + + err := mountAndRun(t, CalendarAgenda, []string{ + "+agenda", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--calendar-id", "cal_my", + "--as", "bot", + }, f, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// CalendarFreebusy tests +// --------------------------------------------------------------------------- + +func TestFreebusy_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/list", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "freebusy_list": []interface{}{ + map[string]interface{}{ + "start_time": "2025-03-21T10:00:00+08:00", + "end_time": "2025-03-21T11:00:00+08:00", + }, + }, + }, + }, + }) + + err := mountAndRun(t, CalendarFreebusy, []string{ + "+freebusy", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--user-id", "ou_someone", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "start_time") { + t.Errorf("stdout should contain freebusy data, got: %s", stdout.String()) + } +} + +func TestFreebusy_BotWithoutUser_Fails(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarFreebusy, []string{ + "+freebusy", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected validation error for bot without --user-id, got nil") + } + if !strings.Contains(err.Error(), "--user-id is required") { + t.Errorf("error should mention --user-id requirement, got: %v", err) + } +} + +func TestFreebusy_APIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/list", + Body: map[string]interface{}{ + "code": 190001, + "msg": "permission denied", + }, + }) + + err := mountAndRun(t, CalendarFreebusy, []string{ + "+freebusy", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--user-id", "ou_someone", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected error for API failure, got nil") + } +} + +// --------------------------------------------------------------------------- +// CalendarSuggestion tests +// --------------------------------------------------------------------------- + +func TestSuggestion_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/suggestion", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "suggestions": []interface{}{ + map[string]interface{}{ + "event_start_time": "2025-03-21T10:00:00+08:00", + "event_end_time": "2025-03-21T11:00:00+08:00", + "recommend_reason": "everyone is free", + }, + }, + "ai_action_guidance": "book it", + }, + }, + }) + + // 正常执行 + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--attendee-ids", "ou_user1,oc_chat1", + "--event-rrule", "FREQ=DAILY;BYDAY=MO", + "--duration-minutes", "60", + "--timezone", "Asia/Shanghai", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "2025-03-21T10:00:00+08:00") { + t.Errorf("stdout should contain start time, got: %s", out) + } + if !strings.Contains(out, "everyone is free") { + t.Errorf("stdout should contain reason, got: %s", out) + } + if !strings.Contains(out, `"ai_action_guidance": "book it"`) { + t.Errorf("stdout should contain guidance, got: %s", out) + } +} + +func TestSuggestion_DryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--attendee-ids", "ou_user1,oc_chat1", + "--event-rrule", "FREQ=DAILY;BYDAY=MO", + "--duration-minutes", "60", + "--timezone", "Asia/Shanghai", + "--dry-run", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSuggestion_Pretty(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/suggestion", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "suggestions": []interface{}{ + map[string]interface{}{ + "event_start_time": "2025-03-21T10:00:00+08:00", + "event_end_time": "2025-03-21T11:00:00+08:00", + "recommend_reason": "everyone is free", + }, + }, + "ai_action_guidance": "book it", + }, + }, + }) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--attendee-ids", "ou_user1,oc_chat1", + "--event-rrule", "FREQ=DAILY;BYDAY=MO", + "--duration-minutes", "60", + "--timezone", "Asia/Shanghai", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSuggestion_DefaultTime(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/suggestion", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "suggestions": []interface{}{ + map[string]interface{}{ + "event_start_time": "2025-03-21T10:00:00+08:00", + "event_end_time": "2025-03-21T11:00:00+08:00", + "recommend_reason": "everyone is free", + }, + }, + "ai_action_guidance": "book it", + }, + }, + }) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSuggestion_ExcludeTime(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/suggestion", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "suggestions": []interface{}{ + map[string]interface{}{ + "event_start_time": "2025-03-21T10:00:00+08:00", + "event_end_time": "2025-03-21T11:00:00+08:00", + "recommend_reason": "everyone is free", + }, + }, + "ai_action_guidance": "book it", + }, + }, + }) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--start", "2025-03-21T14:00:00+08:00", + "--end", "2025-03-21T18:00:00+08:00", + "--duration-minutes", "30", + "--timezone", "Asia/Shanghai", + "--exclude", "2025-03-21T14:00:00+08:00~2025-03-21T14:30:00+08:00,2025-03-21T15:00:00+08:00~2025-03-21T15:30:00+08:00", + "--as", "bot", + }, f, stdout) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestSuggestion_InvalidAttendee_Fails(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--attendee-ids", "invalid_id", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected validation error for invalid attendee id, got nil") + } + if !strings.Contains(err.Error(), "invalid attendee id format") { + t.Errorf("error should mention attendee id format, got: %v", err) + } +} + +func TestSuggestion_InvalidExclude_Fails(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--exclude", "2025-03-21", // missing ~ + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected validation error for invalid exclude format, got nil") + } + if !strings.Contains(err.Error(), "invalid range format in --exclude") { + t.Errorf("error should mention exclude format, got: %v", err) + } +} + +func TestSuggestion_APIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/calendar/v4/freebusy/suggestion", + Body: map[string]interface{}{ + "code": 190001, + "msg": "permission denied", + }, + }) + + err := mountAndRun(t, CalendarSuggestion, []string{ + "+suggestion", + "--start", "2025-03-21", + "--end", "2025-03-21", + "--as", "bot", + }, f, nil) + + if err == nil { + t.Fatal("expected error for API failure, got nil") + } +} + +// --------------------------------------------------------------------------- +// helpers unit tests +// --------------------------------------------------------------------------- + +func TestDedupeAndSortItems(t *testing.T) { + items := []map[string]interface{}{ + {"event_id": "e1", "start_time": map[string]interface{}{"timestamp": "200"}, "end_time": map[string]interface{}{"timestamp": "300"}}, + {"event_id": "e2", "start_time": map[string]interface{}{"timestamp": "100"}, "end_time": map[string]interface{}{"timestamp": "150"}}, + // duplicate of e1 + {"event_id": "e1", "start_time": map[string]interface{}{"timestamp": "200"}, "end_time": map[string]interface{}{"timestamp": "300"}}, + } + + result := dedupeAndSortItems(items) + + if len(result) != 2 { + t.Fatalf("expected 2 items after dedup, got %d", len(result)) + } + id0, _ := result[0]["event_id"].(string) + id1, _ := result[1]["event_id"].(string) + if id0 != "e2" || id1 != "e1" { + t.Errorf("expected order [e2, e1], got [%s, %s]", id0, id1) + } +} + +func TestResolveStartEnd_Defaults(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("start", "", "") + cmd.Flags().String("end", "", "") + cmd.ParseFlags(nil) + + rt := &common.RuntimeContext{Cmd: cmd} + start, end := resolveStartEnd(rt) + + if start == "" { + t.Error("start should not be empty") + } + if end != start { + t.Errorf("end should equal start when both unset, got start=%q end=%q", start, end) + } +} + +func TestResolveStartEnd_ExplicitValues(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("start", "", "") + cmd.Flags().String("end", "", "") + cmd.ParseFlags(nil) + cmd.Flags().Set("start", "2025-03-01") + cmd.Flags().Set("end", "2025-03-15") + + rt := &common.RuntimeContext{Cmd: cmd} + start, end := resolveStartEnd(rt) + + if start != "2025-03-01" { + t.Errorf("start = %q, want 2025-03-01", start) + } + if end != "2025-03-15" { + t.Errorf("end = %q, want 2025-03-15", end) + } +} + +// --------------------------------------------------------------------------- +// Shortcuts() registration test +// --------------------------------------------------------------------------- + +func TestShortcuts_Returns4(t *testing.T) { + shortcuts := Shortcuts() + if len(shortcuts) != 4 { + t.Fatalf("expected 4 shortcuts, got %d", len(shortcuts)) + } + + names := map[string]bool{} + for _, s := range shortcuts { + names[s.Command] = true + } + for _, want := range []string{"+agenda", "+create", "+freebusy", "+suggestion"} { + if !names[want] { + t.Errorf("missing shortcut %s", want) + } + } +} + +func TestShortcuts_AllHaveScopes(t *testing.T) { + for _, s := range Shortcuts() { + if s.Scopes == nil { + t.Errorf("shortcut %s: Scopes is nil", s.Command) + } + } +} diff --git a/shortcuts/calendar/helpers.go b/shortcuts/calendar/helpers.go new file mode 100644 index 00000000..a905b4a1 --- /dev/null +++ b/shortcuts/calendar/helpers.go @@ -0,0 +1,28 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import ( + "time" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + PrimaryCalendarIDStr = "primary" +) + +// resolveStartEnd returns (startInput, endInput) from flags with defaults. +// --start defaults to today's date, --end defaults to start date (will be resolved to end-of-day by caller). +func resolveStartEnd(runtime *common.RuntimeContext) (string, string) { + startInput := runtime.Str("start") + if startInput == "" { + startInput = time.Now().Format("2006-01-02") + } + endInput := runtime.Str("end") + if endInput == "" { + endInput = startInput + } + return startInput, endInput +} diff --git a/shortcuts/calendar/shortcuts.go b/shortcuts/calendar/shortcuts.go new file mode 100644 index 00000000..5f2ca92b --- /dev/null +++ b/shortcuts/calendar/shortcuts.go @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package calendar + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all calendar shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + CalendarAgenda, + CalendarCreate, + CalendarFreebusy, + CalendarSuggestion, + } +} diff --git a/shortcuts/common/common.go b/shortcuts/common/common.go new file mode 100644 index 00000000..36ffabd9 --- /dev/null +++ b/shortcuts/common/common.go @@ -0,0 +1,200 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" +) + +// RequireConfirmation blocks high-risk-write operations unless --yes is passed. +func RequireConfirmation(risk string, yes bool, action string) error { + if risk != "high-risk-write" || yes { + return nil + } + return output.ErrWithHint(output.ExitValidation, "unsafe_operation_blocked", + fmt.Sprintf("high-risk operation requires confirmation: %s", action), + "add --yes to confirm") +} + +func FormatSize(bytes int64) string { + if bytes < 1024 { + return fmt.Sprintf("%d B", bytes) + } + if bytes < 1024*1024 { + return fmt.Sprintf("%.1f KB", float64(bytes)/1024) + } + if bytes < 1024*1024*1024 { + return fmt.Sprintf("%.1f MB", float64(bytes)/1024/1024) + } + return fmt.Sprintf("%.1f GB", float64(bytes)/1024/1024/1024) +} + +func MaskToken(token string) string { + if len(token) < 2 { + return "***" + } + if len(token) <= 8 { + return token[:2] + "***" + } + return token[:4] + "..." + token[len(token)-4:] +} + +// ParseTime converts time expressions to Unix seconds string. +// +// Optional hint: "end" makes day-granularity inputs snap to 23:59:59 instead of 00:00:00. +// +// ParseTime("2026-01-01") → 2026-01-01 00:00:00 +// ParseTime("2026-01-01", "end") → 2026-01-01 23:59:59 +// +// Supported formats: ISO 8601 (with or without time/timezone), date-only, Unix timestamp. +func ParseTime(input string, hint ...string) (string, error) { + input = strings.TrimSpace(input) + isEnd := len(hint) > 0 && hint[0] == "end" + + // snapDay aligns to start-of-day or end-of-day based on hint. + snapDay := func(t time.Time) time.Time { + if isEnd { + return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location()) + } + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) + } + + // ISO 8601 with timezone (precise) + tzFormats := []string{ + time.RFC3339, + "2006-01-02T15:04Z07:00", + "2006-01-02T15:04:05Z07:00", + } + for _, f := range tzFormats { + if t, err := time.Parse(f, input); err == nil { + return fmt.Sprintf("%d", t.Unix()), nil + } + } + // ISO 8601 without timezone — with time component (precise) + preciseFormats := []string{ + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02T15:04", + "2006-01-02 15:04", + } + for _, f := range preciseFormats { + if t, err := time.ParseInLocation(f, input, time.Local); err == nil { + return fmt.Sprintf("%d", t.Unix()), nil + } + } + // Date-only (day-granularity) + if t, err := time.ParseInLocation("2006-01-02", input, time.Local); err == nil { + return fmt.Sprintf("%d", snapDay(t).Unix()), nil + } + // Unix timestamp (precise, passed through as-is) — must be purely numeric + var ts int64 + if n, err := fmt.Sscanf(input, "%d", &ts); err == nil && n == 1 && ts > 0 && fmt.Sprintf("%d", ts) == input { + return input, nil + } + return "", fmt.Errorf("cannot parse time %q (supported: ISO 8601 e.g. 2026-01-01 / 2026-01-01T15:04:05+08:00, Unix timestamp)", input) +} + +// FormatTimeWithSeconds converts Unix seconds/ms string to local time string with seconds precision. +func FormatTimeWithSeconds(ts interface{}) string { + if ts == nil { + return "" + } + s := fmt.Sprintf("%v", ts) + if s == "" { + return "" + } + var n int64 + fmt.Sscanf(s, "%d", &n) + if n == 0 { + return s + } + if n > 1e12 { + n = n / 1000 + } + t := time.Unix(n, 0) + return t.Local().Format("2006-01-02 15:04:05") +} + +// FormatTime converts Unix seconds/ms string to local time string. +func FormatTime(ts interface{}) string { + if ts == nil { + return "" + } + s := fmt.Sprintf("%v", ts) + if s == "" { + return "" + } + var n int64 + fmt.Sscanf(s, "%d", &n) + if n == 0 { + return s + } + // Detect ms vs seconds + if n > 1e12 { + n = n / 1000 + } + t := time.Unix(n, 0) + return t.Local().Format("2006-01-02 15:04") +} + +// SplitCSV 解析逗号分隔的列表,忽略空项并去除空格 +func SplitCSV(input string) []string { + if input == "" { + return nil + } + parts := strings.Split(input, ",") + var result []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +// CheckApiError checks if API result is an error and prints it to w. +func CheckApiError(w io.Writer, result interface{}, action string) bool { + if resultMap, ok := result.(map[string]interface{}); ok { + code, _ := util.ToFloat64(resultMap["code"]) + if code != 0 { + msg, _ := resultMap["msg"].(string) + output.PrintError(w, fmt.Sprintf("%s: [%.0f] %s", action, code, msg)) + return true + } + } + return false +} + +// HandleApiResult checks for network/API errors and returns the "data" field. +func HandleApiResult(result interface{}, err error, action string) (map[string]interface{}, error) { + if err != nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err) + } + resultMap, _ := result.(map[string]interface{}) + code, _ := util.ToFloat64(resultMap["code"]) + if code != 0 { + msg, _ := resultMap["msg"].(string) + larkCode := int(code) + fullMsg := fmt.Sprintf("%s: [%d] %s", action, larkCode, msg) + return nil, output.ErrAPI(larkCode, fullMsg, resultMap["error"]) + } + data, _ := resultMap["data"].(map[string]interface{}) + return data, nil +} + +// TruncateStr truncates s to at most n runes. +func TruncateStr(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} diff --git a/shortcuts/common/common_test.go b/shortcuts/common/common_test.go new file mode 100644 index 00000000..7c8f02e9 --- /dev/null +++ b/shortcuts/common/common_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "testing" + "time" +) + +func TestParseTimeISO(t *testing.T) { + s, err := ParseTime("2026-01-15") + if err != nil { + t.Fatalf("ParseTime(date) error: %v", err) + } + ts, _ := strconv.ParseInt(s, 10, 64) + parsed := time.Unix(ts, 0) + if parsed.Year() != 2026 || parsed.Month() != 1 || parsed.Day() != 15 { + t.Errorf("ParseTime(2026-01-15) = %v", parsed) + } +} + +func TestParseTimeUnix(t *testing.T) { + ts := fmt.Sprintf("%d", time.Now().Unix()) + s, err := ParseTime(ts) + if err != nil { + t.Fatalf("ParseTime(unix) error: %v", err) + } + if s != ts { + t.Errorf("ParseTime(%q) = %q, want pass-through", ts, s) + } +} + +func TestParseTimeRejectsRelative(t *testing.T) { + for _, input := range []string{"today", "tomorrow", "yesterday", "now", "this_week", "+3d", "-1w", "+2h", "-30m", "last_7_days"} { + t.Run(input, func(t *testing.T) { + _, err := ParseTime(input) + if err == nil { + t.Errorf("ParseTime(%q) should return error, but got nil", input) + } + }) + } +} + +func TestParseTimeEndHint(t *testing.T) { + s, err := ParseTime("2026-03-15", "end") + if err != nil { + t.Fatalf("ParseTime(date, end) error: %v", err) + } + ts, _ := strconv.ParseInt(s, 10, 64) + parsed := time.Unix(ts, 0) + if parsed.Hour() != 23 || parsed.Minute() != 59 || parsed.Second() != 59 { + t.Errorf("ParseTime(2026-03-15, end) = %v, want 23:59:59", parsed) + } +} + +func TestEnsureWritableFile(t *testing.T) { + t.Run("allows missing target", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.txt") + if err := EnsureWritableFile(path, false); err != nil { + t.Fatalf("EnsureWritableFile() unexpected error: %v", err) + } + }) + + t.Run("rejects existing target without overwrite", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "exists.txt") + if err := os.WriteFile(path, []byte("data"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + if err := EnsureWritableFile(path, false); err == nil { + t.Fatalf("expected overwrite protection error, got nil") + } + }) + + t.Run("allows existing target with overwrite", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "exists.txt") + if err := os.WriteFile(path, []byte("data"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + if err := EnsureWritableFile(path, true); err != nil { + t.Fatalf("EnsureWritableFile() unexpected error: %v", err) + } + }) +} diff --git a/shortcuts/common/dryrun.go b/shortcuts/common/dryrun.go new file mode 100644 index 00000000..5b90ee29 --- /dev/null +++ b/shortcuts/common/dryrun.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "github.com/larksuite/cli/internal/cmdutil" + +// Type aliases so all existing shortcut code continues to use common.DryRunAPI +// without any changes. The real implementation lives in internal/cmdutil. +type DryRunAPI = cmdutil.DryRunAPI +type DryRunAPICall = cmdutil.DryRunAPICall + +var NewDryRunAPI = cmdutil.NewDryRunAPI diff --git a/shortcuts/common/extract.go b/shortcuts/common/extract.go new file mode 100644 index 00000000..382977ed --- /dev/null +++ b/shortcuts/common/extract.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "github.com/larksuite/cli/internal/util" + +// GetString safely extracts a string from a nested map path. +// Usage: GetString(data, "user", "name") is equivalent to +// data["user"].(map[string]interface{})["name"].(string) +func GetString(m map[string]interface{}, keys ...string) string { + if len(keys) == 0 { + return "" + } + v := navigate(m, keys[:len(keys)-1]) + if v == nil { + return "" + } + s, _ := v[keys[len(keys)-1]].(string) + return s +} + +// GetFloat safely extracts a float64 (the default JSON number type). +func GetFloat(m map[string]interface{}, keys ...string) float64 { + if len(keys) == 0 { + return 0 + } + v := navigate(m, keys[:len(keys)-1]) + if v == nil { + return 0 + } + f, _ := util.ToFloat64(v[keys[len(keys)-1]]) + return f +} + +// GetBool safely extracts a bool. +func GetBool(m map[string]interface{}, keys ...string) bool { + if len(keys) == 0 { + return false + } + v := navigate(m, keys[:len(keys)-1]) + if v == nil { + return false + } + b, _ := v[keys[len(keys)-1]].(bool) + return b +} + +// GetMap safely extracts a nested map. +func GetMap(m map[string]interface{}, keys ...string) map[string]interface{} { + if len(keys) == 0 { + return m + } + return navigate(m, keys) +} + +// GetSlice safely extracts a []interface{}. +func GetSlice(m map[string]interface{}, keys ...string) []interface{} { + if len(keys) == 0 { + return nil + } + v := navigate(m, keys[:len(keys)-1]) + if v == nil { + return nil + } + s, _ := v[keys[len(keys)-1]].([]interface{}) + return s +} + +// EachMap iterates over map elements in a slice, skipping non-map items. +func EachMap(items []interface{}, fn func(m map[string]interface{})) { + if fn == nil { + return + } + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + fn(m) + } + } +} + +// navigate walks a map along the given keys, returning nil if any step fails. +func navigate(m map[string]interface{}, keys []string) map[string]interface{} { + cur := m + for _, k := range keys { + next, ok := cur[k].(map[string]interface{}) + if !ok { + return nil + } + cur = next + } + return cur +} diff --git a/shortcuts/common/extract_test.go b/shortcuts/common/extract_test.go new file mode 100644 index 00000000..373bfc90 --- /dev/null +++ b/shortcuts/common/extract_test.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "testing" +) + +func TestGetString(t *testing.T) { + m := map[string]interface{}{ + "name": "Alice", + "user": map[string]interface{}{ + "id": "u123", + "name": "Bob", + "profile": map[string]interface{}{ + "email": "bob@example.com", + }, + }, + } + + tests := []struct { + name string + keys []string + want string + }{ + {"top level", []string{"name"}, "Alice"}, + {"nested one level", []string{"user", "id"}, "u123"}, + {"nested two levels", []string{"user", "profile", "email"}, "bob@example.com"}, + {"missing key", []string{"missing"}, ""}, + {"missing nested", []string{"user", "missing"}, ""}, + {"wrong type", []string{"user"}, ""}, + {"empty keys", []string{}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetString(m, tt.keys...) + if got != tt.want { + t.Errorf("GetString() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGetFloat(t *testing.T) { + m := map[string]interface{}{ + "count": 42.0, + "data": map[string]interface{}{ + "score": 99.5, + }, + } + + if got := GetFloat(m, "count"); got != 42.0 { + t.Errorf("GetFloat(count) = %f, want 42.0", got) + } + if got := GetFloat(m, "data", "score"); got != 99.5 { + t.Errorf("GetFloat(data.score) = %f, want 99.5", got) + } + if got := GetFloat(m, "missing"); got != 0 { + t.Errorf("GetFloat(missing) = %f, want 0", got) + } + if got := GetFloat(m); got != 0 { + t.Errorf("GetFloat() = %f, want 0", got) + } +} + +func TestGetBool(t *testing.T) { + m := map[string]interface{}{ + "active": true, + "data": map[string]interface{}{ + "verified": false, + }, + } + + if got := GetBool(m, "active"); got != true { + t.Errorf("GetBool(active) = %v, want true", got) + } + if got := GetBool(m, "data", "verified"); got != false { + t.Errorf("GetBool(data.verified) = %v, want false", got) + } + if got := GetBool(m, "missing"); got != false { + t.Errorf("GetBool(missing) = %v, want false", got) + } + if got := GetBool(m); got != false { + t.Errorf("GetBool() = %v, want false", got) + } +} + +func TestGetMap(t *testing.T) { + inner := map[string]interface{}{"key": "val"} + m := map[string]interface{}{ + "data": inner, + } + + got := GetMap(m, "data") + if got == nil || got["key"] != "val" { + t.Errorf("GetMap(data) = %v, want %v", got, inner) + } + if got := GetMap(m, "missing"); got != nil { + t.Errorf("GetMap(missing) = %v, want nil", got) + } + // No keys returns the original map. + if got := GetMap(m); got == nil { + t.Errorf("GetMap() = nil, want original map") + } +} + +func TestGetSlice(t *testing.T) { + items := []interface{}{"a", "b"} + m := map[string]interface{}{ + "items": items, + "data": map[string]interface{}{ + "list": []interface{}{1.0, 2.0}, + }, + } + + got := GetSlice(m, "items") + if len(got) != 2 { + t.Errorf("GetSlice(items) len = %d, want 2", len(got)) + } + got = GetSlice(m, "data", "list") + if len(got) != 2 { + t.Errorf("GetSlice(data.list) len = %d, want 2", len(got)) + } + if got := GetSlice(m, "missing"); got != nil { + t.Errorf("GetSlice(missing) = %v, want nil", got) + } + if got := GetSlice(m); got != nil { + t.Errorf("GetSlice() = %v, want nil", got) + } +} + +func TestEachMap(t *testing.T) { + items := []interface{}{ + map[string]interface{}{"id": "1"}, + "not a map", + map[string]interface{}{"id": "2"}, + 42, + } + + var ids []string + EachMap(items, func(m map[string]interface{}) { + ids = append(ids, m["id"].(string)) + }) + + if len(ids) != 2 || ids[0] != "1" || ids[1] != "2" { + t.Errorf("EachMap collected ids = %v, want [1 2]", ids) + } +} + +func TestNavigateNilMap(t *testing.T) { + var m map[string]interface{} + if got := GetString(m, "key"); got != "" { + t.Errorf("GetString(nil, key) = %q, want empty", got) + } +} diff --git a/shortcuts/common/helpers.go b/shortcuts/common/helpers.go new file mode 100644 index 00000000..0a5b8e7a --- /dev/null +++ b/shortcuts/common/helpers.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "encoding/json" + "errors" + "io" + "mime/multipart" + "net/textproto" + "os" + + "github.com/larksuite/cli/internal/output" +) + +// MultipartWriter wraps multipart.Writer for file uploads. +type MultipartWriter struct { + *multipart.Writer +} + +// NewMultipartWriter creates a new MultipartWriter. +func NewMultipartWriter(w io.Writer) *MultipartWriter { + return &MultipartWriter{multipart.NewWriter(w)} +} + +// CreateFormFile creates a form file with the given field name and file name. +func (mw *MultipartWriter) CreateFormFile(fieldname, filename string) (io.Writer, error) { + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", `form-data; name="`+fieldname+`"; filename="`+filename+`"`) + h.Set("Content-Type", "application/octet-stream") + return mw.Writer.CreatePart(h) +} + +// ParseJSON unmarshals JSON data into v. +func ParseJSON(data []byte, v interface{}) error { + return json.Unmarshal(data, v) +} + +// EnsureWritableFile refuses to overwrite an existing file unless overwrite is true. +func EnsureWritableFile(path string, overwrite bool) error { + if overwrite { + return nil + } + if _, err := os.Stat(path); err == nil { + return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", path) + } else if !errors.Is(err, os.ErrNotExist) { + return output.Errorf(output.ExitInternal, "io", "cannot access output path %s: %v", path, err) + } + return nil +} diff --git a/shortcuts/common/mcp_client.go b/shortcuts/common/mcp_client.go new file mode 100644 index 00000000..5e87eb66 --- /dev/null +++ b/shortcuts/common/mcp_client.go @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" +) + +const mcpErrorBodyLimit = 4000 + +func MCPEndpoint(brand core.LarkBrand) string { + return core.ResolveEndpoints(brand).MCP + "/mcp" +} + +// CallMCPTool calls an MCP tool via JSON-RPC 2.0 and returns the parsed result. +func CallMCPTool(runtime *RuntimeContext, toolName string, args map[string]interface{}) (map[string]interface{}, error) { + accessToken, err := runtime.AccessToken() + if err != nil { + return nil, err + } + + httpClient, err := runtime.Factory.HttpClient() + if err != nil { + return nil, output.ErrNetwork("failed to get HTTP client: %v", err) + } + + raw, err := DoMCPCall(runtime.Ctx(), httpClient, toolName, args, accessToken, MCPEndpoint(runtime.Config.Brand), runtime.IsBot()) + if err != nil { + return nil, err + } + + return normalizeMCPToolResult(raw) +} + +func normalizeMCPToolResult(raw interface{}) (map[string]interface{}, error) { + result := ExtractMCPResult(raw) + if m, ok := result.(map[string]interface{}); ok { + if errMsg, ok := m["error"].(string); ok && strings.TrimSpace(errMsg) != "" { + return nil, output.Errorf(output.ExitAPI, "mcp_error", "MCP: %s", errMsg) + } + return m, nil + } + if s, ok := result.(string); ok { + return map[string]interface{}{"message": s}, nil + } + return map[string]interface{}{"result": result}, nil +} + +func DoMCPCall(ctx context.Context, httpClient *http.Client, toolName string, args map[string]interface{}, accessToken string, mcpEndpoint string, isBot bool) (interface{}, error) { + body := map[string]interface{}{ + "jsonrpc": "2.0", + "id": uuid.NewString(), + "method": "tools/call", + "params": map[string]interface{}{ + "name": toolName, + "arguments": args, + }, + } + + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, output.Errorf(output.ExitInternal, "internal_error", "failed to marshal MCP request body: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, mcpEndpoint, bytes.NewReader(jsonBody)) + if err != nil { + return nil, output.Errorf(output.ExitInternal, "internal_error", "failed to create MCP request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + if isBot { + req.Header.Set("X-Lark-MCP-TAT", accessToken) + } else { + req.Header.Set("X-Lark-MCP-UAT", accessToken) + } + req.Header.Set("X-Lark-MCP-Allowed-Tools", toolName) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, output.ErrNetwork("MCP transport failed: %v", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, output.ErrNetwork("failed to read MCP response: %v", err) + } + if resp.StatusCode >= 400 { + return nil, classifyMCPHTTPError(resp.StatusCode, resp.Status, respBody) + } + + var data map[string]interface{} + if err := json.Unmarshal(respBody, &data); err != nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "MCP returned non-JSON: %s", TruncateStr(string(respBody), mcpErrorBodyLimit)) + } + + if errObj, ok := data["error"]; ok { + return nil, classifyMCPPayloadError(errObj) + } + + return UnwrapMCPResult(data["result"]), nil +} + +func classifyMCPHTTPError(statusCode int, status string, body []byte) error { + var payload map[string]interface{} + if err := json.Unmarshal(body, &payload); err == nil { + if errObj, ok := payload["error"]; ok { + return classifyMCPPayloadError(errObj) + } + if code, msg, detail, ok := extractMCPBusinessError(payload); ok { + return output.ErrAPI(code, fmt.Sprintf("MCP HTTP %d %s: [%d] %s", statusCode, status, code, msg), detail) + } + } + + bodyText := TruncateStr(strings.TrimSpace(string(body)), mcpErrorBodyLimit) + if statusCode == http.StatusUnauthorized { + return output.ErrAuth("MCP HTTP %d %s: %s", statusCode, status, bodyText) + } + return output.Errorf(output.ExitAPI, "api_error", "MCP HTTP %d %s: %s", statusCode, status, bodyText) +} + +func classifyMCPPayloadError(errObj interface{}) error { + if errMap, ok := errObj.(map[string]interface{}); ok { + msg := GetString(errMap, "message") + if msg == "" { + msg = GetString(errMap, "msg") + } + if code, ok := util.ToFloat64(errMap["code"]); ok { + return output.ErrAPI(int(code), fmt.Sprintf("MCP: [%.0f] %s", code, msg), errMap) + } + if msg != "" { + return classifyMCPMessageError(fmt.Sprintf("MCP: %s", msg), errMap) + } + } + + if msg, ok := errObj.(string); ok && strings.TrimSpace(msg) != "" { + return classifyMCPMessageError(fmt.Sprintf("MCP: %s", msg), errObj) + } + + return output.Errorf(output.ExitAPI, "api_error", "MCP returned an error response") +} + +func classifyMCPMessageError(msg string, detail interface{}) error { + lower := strings.ToLower(msg) + switch { + case strings.Contains(lower, "unauthorized"), + strings.Contains(lower, "access token"), + strings.Contains(lower, "token invalid"), + strings.Contains(lower, "token expired"): + return &output.ExitError{ + Code: output.ExitAuth, + Detail: &output.ErrDetail{ + Type: "auth", + Message: msg, + Hint: "run `lark-cli auth login` in the background to re-authorize. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", + Detail: detail, + }, + } + default: + code, errType, hint := output.ClassifyLarkError(0, msg) + return &output.ExitError{ + Code: code, + Detail: &output.ErrDetail{ + Type: errType, + Message: msg, + Hint: hint, + Detail: detail, + }, + } + } +} + +func extractMCPBusinessError(payload map[string]interface{}) (int, string, interface{}, bool) { + code, ok := util.ToFloat64(payload["code"]) + if !ok || code == 0 { + return 0, "", nil, false + } + + msg := GetString(payload, "msg") + if msg == "" { + msg = GetString(payload, "message") + } + if msg == "" { + msg = "unknown MCP error" + } + return int(code), msg, payload["error"], true +} + +func UnwrapMCPResult(v interface{}) interface{} { + m, ok := v.(map[string]interface{}) + if !ok { + return v + } + _, hasJSONRPC := m["jsonrpc"] + _, hasResult := m["result"] + _, hasError := m["error"] + + if hasJSONRPC && (hasResult || hasError) { + if hasError { + return v + } + return UnwrapMCPResult(m["result"]) + } + if !hasJSONRPC && hasResult && !hasError { + return UnwrapMCPResult(m["result"]) + } + return v +} + +func ExtractMCPResult(raw interface{}) interface{} { + m, ok := raw.(map[string]interface{}) + if !ok { + return raw + } + + content, ok := m["content"].([]interface{}) + if !ok { + return raw + } + if len(content) == 1 { + if item, ok := content[0].(map[string]interface{}); ok && item["type"] == "text" { + text, _ := item["text"].(string) + var parsed interface{} + if err := json.Unmarshal([]byte(text), &parsed); err == nil { + return parsed + } + return text + } + } + + texts := make([]string, 0, len(content)) + for _, item := range content { + textItem, ok := item.(map[string]interface{}) + if !ok { + continue + } + if text, ok := textItem["text"].(string); ok { + texts = append(texts, text) + } + } + return strings.Join(texts, "\n") +} diff --git a/shortcuts/common/mcp_client_test.go b/shortcuts/common/mcp_client_test.go new file mode 100644 index 00000000..2550652b --- /dev/null +++ b/shortcuts/common/mcp_client_test.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/internal/output" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestDoMCPCallTransportError(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp: timeout") + }), + } + + _, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "uat-token", "https://example.com/mcp", false) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %v", err) + } + if exitErr.Code != output.ExitNetwork { + t.Fatalf("expected network exit code, got %d", exitErr.Code) + } +} + +func TestDoMCPCallUnauthorizedHTTPError(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Status: "401 Unauthorized", + Body: io.NopCloser(strings.NewReader("unauthorized")), + }, nil + }), + } + + _, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "uat-token", "https://example.com/mcp", false) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %v", err) + } + if exitErr.Code != output.ExitAuth { + t.Fatalf("expected auth exit code, got %d", exitErr.Code) + } +} + +func TestDoMCPCallJSONRPCErrorUsesLarkClassification(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(`{"error":{"code":99991668,"message":"user_access_token invalid"}}`)), + }, nil + }), + } + + _, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "uat-token", "https://example.com/mcp", false) + var exitErr *output.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected ExitError, got %v", err) + } + if exitErr.Code != output.ExitAuth { + t.Fatalf("expected auth exit code, got %d", exitErr.Code) + } + if exitErr.Detail == nil || exitErr.Detail.Type != "auth" { + t.Fatalf("expected auth detail, got %#v", exitErr.Detail) + } +} + +func TestDoMCPCallSetsHeadersAndUnwrapsResult(t *testing.T) { + t.Parallel() + + var seen *http.Request + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + seen = req + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Body: io.NopCloser(strings.NewReader(`{"result":{"jsonrpc":"2.0","result":{"ok":true}}}`)), + }, nil + }), + } + + got, err := DoMCPCall(context.Background(), client, "fetch-doc", map[string]interface{}{"doc_id": "doc_1"}, "tat-token", "https://example.com/mcp", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result, ok := got.(map[string]interface{}) + if !ok || result["ok"] != true { + t.Fatalf("unexpected result: %#v", got) + } + if seen == nil { + t.Fatalf("expected request to be captured") + } + if seen.Header.Get("X-Lark-MCP-TAT") != "tat-token" { + t.Fatalf("expected bot token header, got %q", seen.Header.Get("X-Lark-MCP-TAT")) + } + if seen.Header.Get("X-Lark-MCP-Allowed-Tools") != "fetch-doc" { + t.Fatalf("expected allowed tools header, got %q", seen.Header.Get("X-Lark-MCP-Allowed-Tools")) + } +} + +func TestNormalizeMCPToolResult(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw interface{} + wantKey string + wantVal interface{} + wantErr string + }{ + { + name: "map result", + raw: map[string]interface{}{"ok": true}, + wantKey: "ok", + wantVal: true, + }, + { + name: "text result", + raw: "plain text", + wantKey: "message", + wantVal: "plain text", + }, + { + name: "scalar result", + raw: 42, + wantKey: "result", + wantVal: 42, + }, + { + name: "map error field", + raw: map[string]interface{}{"error": "permission denied"}, + wantErr: "MCP: permission denied", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := normalizeMCPToolResult(tt.raw) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got[tt.wantKey] != tt.wantVal { + t.Fatalf("unexpected result: %#v", got) + } + }) + } +} + +func TestExtractMCPResult(t *testing.T) { + t.Parallel() + + jsonResult := ExtractMCPResult(map[string]interface{}{ + "content": []interface{}{ + map[string]interface{}{ + "type": "text", + "text": `{"doc_id":"doc_1"}`, + }, + }, + }) + resultMap, ok := jsonResult.(map[string]interface{}) + if !ok || resultMap["doc_id"] != "doc_1" { + t.Fatalf("unexpected parsed json result: %#v", jsonResult) + } + + textResult := ExtractMCPResult(map[string]interface{}{ + "content": []interface{}{ + map[string]interface{}{"type": "text", "text": "line1"}, + map[string]interface{}{"type": "text", "text": "line2"}, + }, + }) + if textResult != "line1\nline2" { + t.Fatalf("unexpected text result: %#v", textResult) + } +} diff --git a/shortcuts/common/pagination.go b/shortcuts/common/pagination.go new file mode 100644 index 00000000..40ffc9a1 --- /dev/null +++ b/shortcuts/common/pagination.go @@ -0,0 +1,25 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "fmt" + +// PaginationMeta extracts pagination metadata from an API response data map. +func PaginationMeta(data map[string]interface{}) (hasMore bool, pageToken string) { + hasMore, _ = data["has_more"].(bool) + pageToken, _ = data["page_token"].(string) + if pageToken == "" { + pageToken, _ = data["next_page_token"].(string) + } + return +} + +// PaginationHint returns a human-readable pagination hint for pretty output. +func PaginationHint(data map[string]interface{}, count int) string { + hasMore, token := PaginationMeta(data) + if !hasMore { + return fmt.Sprintf("\n%d total\n", count) + } + return fmt.Sprintf("\n%d total (more available, page_token: %s)\n", count, token) +} diff --git a/shortcuts/common/pagination_test.go b/shortcuts/common/pagination_test.go new file mode 100644 index 00000000..429451db --- /dev/null +++ b/shortcuts/common/pagination_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "strings" + "testing" +) + +func TestPaginationMeta(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + wantMore bool + wantToken string + }{ + { + name: "has more with page_token", + data: map[string]interface{}{"has_more": true, "page_token": "abc"}, + wantMore: true, + wantToken: "abc", + }, + { + name: "has more with next_page_token", + data: map[string]interface{}{"has_more": true, "next_page_token": "def"}, + wantMore: true, + wantToken: "def", + }, + { + name: "page_token preferred over next_page_token", + data: map[string]interface{}{"has_more": true, "page_token": "abc", "next_page_token": "def"}, + wantMore: true, + wantToken: "abc", + }, + { + name: "no more", + data: map[string]interface{}{"has_more": false}, + wantMore: false, + wantToken: "", + }, + { + name: "empty data", + data: map[string]interface{}{}, + wantMore: false, + wantToken: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hasMore, token := PaginationMeta(tt.data) + if hasMore != tt.wantMore { + t.Errorf("hasMore = %v, want %v", hasMore, tt.wantMore) + } + if token != tt.wantToken { + t.Errorf("token = %q, want %q", token, tt.wantToken) + } + }) + } +} + +func TestPaginationHint(t *testing.T) { + t.Run("no more", func(t *testing.T) { + data := map[string]interface{}{"has_more": false} + hint := PaginationHint(data, 5) + if !strings.Contains(hint, "5 total") { + t.Errorf("hint = %q, want to contain '5 total'", hint) + } + if strings.Contains(hint, "more available") { + t.Errorf("hint should not contain 'more available'") + } + }) + + t.Run("has more", func(t *testing.T) { + data := map[string]interface{}{"has_more": true, "page_token": "tok123"} + hint := PaginationHint(data, 10) + if !strings.Contains(hint, "10 total") { + t.Errorf("hint = %q, want to contain '10 total'", hint) + } + if !strings.Contains(hint, "more available") { + t.Errorf("hint = %q, want to contain 'more available'", hint) + } + if !strings.Contains(hint, "tok123") { + t.Errorf("hint = %q, want to contain page token", hint) + } + }) +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go new file mode 100644 index 00000000..1f1df8bc --- /dev/null +++ b/shortcuts/common/runner.go @@ -0,0 +1,697 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/spf13/cobra" +) + +// RuntimeContext provides helpers for shortcut execution. +type RuntimeContext struct { + ctx context.Context // from cmd.Context(), propagated through the call chain + Config *core.CliConfig + Cmd *cobra.Command + Format string + botOnly bool // set by framework for bot-only shortcuts + resolvedAs core.Identity // effective identity resolved by framework + Factory *cmdutil.Factory // injected by framework + apiClient *client.APIClient // lazily initialized, cached + larkSDK *lark.Client // eagerly initialized in mountDeclarative +} + +// ── Identity ── + +// As returns the current identity. +// For bot-only shortcuts, always returns AsBot. +// For dual-auth shortcuts, uses the resolved identity (respects default-as config). +func (ctx *RuntimeContext) As() core.Identity { + if ctx.botOnly { + return core.AsBot + } + if ctx.resolvedAs.IsBot() { + return core.AsBot + } + if ctx.resolvedAs != "" { + return ctx.resolvedAs + } + return core.AsUser +} + +// IsBot returns true if current identity is bot. +func (ctx *RuntimeContext) IsBot() bool { + return ctx.As().IsBot() +} + +// UserOpenId returns the current user's open_id from config. +func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId } + +// Ctx returns the context.Context propagated from cmd.Context(). +func (ctx *RuntimeContext) Ctx() context.Context { return ctx.ctx } + +// getAPIClient returns the cached APIClient, creating it on first use. +func (ctx *RuntimeContext) getAPIClient() (*client.APIClient, error) { + if ctx.apiClient != nil { + return ctx.apiClient, nil + } + ac, err := ctx.Factory.NewAPIClient() + if err != nil { + return nil, err + } + // Override config with the one resolved for this context (may differ from Factory's) + ac.Config = ctx.Config + ctx.apiClient = ac + return ac, nil +} + +// AccessToken returns a valid access token for the current identity. +// For user: returns user access token (with auto-refresh). +// For bot: returns tenant access token. +func (ctx *RuntimeContext) AccessToken() (string, error) { + if ctx.IsBot() { + ac, err := ctx.getAPIClient() + if err != nil { + return "", output.ErrAuth("failed to get SDK: %s", err) + } + tatResp, err := ac.SDK.GetTenantAccessTokenBySelfBuiltApp(ctx.ctx, &larkcore.SelfBuiltTenantAccessTokenReq{ + AppID: ctx.Config.AppID, + AppSecret: ctx.Config.AppSecret, + }) + if err != nil { + return "", output.ErrAuth("failed to get tenant access token: %s", err) + } + return tatResp.TenantAccessToken, nil + } + httpClient, err := ctx.Factory.HttpClient() + if err != nil { + return "", output.ErrAuth("failed to get HTTP client: %s", err) + } + token, err := auth.GetValidAccessToken(httpClient, auth.NewUATCallOptions(ctx.Config, ctx.IO().ErrOut)) + if err != nil { + return "", output.ErrAuth("failed to get access token: %s", err) + } + return token, nil +} + +// LarkSDK returns the eagerly-initialized Lark SDK client. +func (ctx *RuntimeContext) LarkSDK() *lark.Client { + return ctx.larkSDK +} + +// ── Flag accessors ── + +// Str returns a string flag value. +func (ctx *RuntimeContext) Str(name string) string { + v, _ := ctx.Cmd.Flags().GetString(name) + return v +} + +// Bool returns a bool flag value. +func (ctx *RuntimeContext) Bool(name string) bool { + v, _ := ctx.Cmd.Flags().GetBool(name) + return v +} + +// Int returns an int flag value. +func (ctx *RuntimeContext) Int(name string) int { + v, _ := ctx.Cmd.Flags().GetInt(name) + return v +} + +// StrArray returns a string-array flag value (repeated flag, no CSV splitting). +func (ctx *RuntimeContext) StrArray(name string) []string { + v, _ := ctx.Cmd.Flags().GetStringArray(name) + return v +} + +// ── API helpers ── + +// CallAPI uses an internal HTTP wrapper with limited control over request/response. +// +// Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options. +// +// CallAPI calls the Lark API using the current identity (ctx.As()) and auto-handles errors. +func (ctx *RuntimeContext) CallAPI(method, url string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { + result, err := ctx.callRaw(method, url, params, data) + return HandleApiResult(result, err, "API call failed") +} + +// Deprecated: RawAPI uses an internal HTTP wrapper with limited control over request/response. +// Prefer DoAPI for new code — it calls the Lark SDK directly and supports file upload/download options. +// +// RawAPI calls the Lark API using the current identity (ctx.As()) and returns raw result for manual error handling. +func (ctx *RuntimeContext) RawAPI(method, url string, params map[string]interface{}, data interface{}) (interface{}, error) { + return ctx.callRaw(method, url, params, data) +} + +// PaginateAll fetches all pages and returns a single merged result. +func (ctx *RuntimeContext) PaginateAll(method, url string, params map[string]interface{}, data interface{}, opts client.PaginationOptions) (interface{}, error) { + ac, err := ctx.getAPIClient() + if err != nil { + return nil, err + } + req := ctx.buildRequest(method, url, params, data) + return ac.PaginateAll(ctx.ctx, req, opts) +} + +// StreamPages fetches all pages and streams each page's items via onItems. +// Returns the last result (for error checking) and whether any list items were found. +func (ctx *RuntimeContext) StreamPages(method, url string, params map[string]interface{}, data interface{}, onItems func([]interface{}), opts client.PaginationOptions) (interface{}, bool, error) { + ac, err := ctx.getAPIClient() + if err != nil { + return nil, false, err + } + req := ctx.buildRequest(method, url, params, data) + return ac.StreamPages(ctx.ctx, req, onItems, opts) +} + +func (ctx *RuntimeContext) buildRequest(method, url string, params map[string]interface{}, data interface{}) client.RawApiRequest { + req := client.RawApiRequest{ + Method: method, + URL: url, + Params: params, + Data: data, + As: ctx.As(), + } + if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil { + req.ExtraOpts = append(req.ExtraOpts, optFn) + } + return req +} + +func (ctx *RuntimeContext) callRaw(method, url string, params map[string]interface{}, data interface{}) (interface{}, error) { + ac, err := ctx.getAPIClient() + if err != nil { + return nil, err + } + return ac.CallAPI(ctx.ctx, ctx.buildRequest(method, url, params, data)) +} + +// DoAPI executes a raw Lark SDK request with automatic auth handling. +// Unlike CallAPI which parses JSON and extracts the "data" field, DoAPI returns +// the raw *larkcore.ApiResp — suitable for file downloads (WithFileDownload) +// and uploads (WithFileUpload). +// +// Auth resolution is delegated to APIClient.DoSDKRequest to avoid duplicating +// the identity → token logic across the generic and shortcut API paths. +func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) { + ac, err := ctx.getAPIClient() + if err != nil { + return nil, err + } + if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil { + opts = append(opts, optFn) + } + return ac.DoSDKRequest(ctx.ctx, req, ctx.As(), opts...) +} + +type cancelOnCloseReadCloser struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (r *cancelOnCloseReadCloser) Close() error { + err := r.ReadCloser.Close() + if r.cancel != nil { + r.cancel() + } + return err +} + +// DoAPIStream executes a streaming HTTP request against the Lark OpenAPI endpoint +// while preserving the framework's auth resolution, shortcut headers, and security headers. +func (ctx *RuntimeContext) DoAPIStream(callCtx context.Context, req *larkcore.ApiReq, timeout time.Duration, opts ...larkcore.RequestOptionFunc) (*http.Response, error) { + httpClient, err := ctx.Factory.HttpClient() + if err != nil { + return nil, output.ErrNetwork("stream request failed: %s", err) + } + + streamingClient := *httpClient + if timeout > 0 { + streamingClient.Timeout = timeout + } + + requestCtx := callCtx + cancel := func() {} + if timeout > 0 { + if _, hasDeadline := callCtx.Deadline(); !hasDeadline { + requestCtx, cancel = context.WithTimeout(callCtx, timeout) + } + } + + var option larkcore.RequestOption + for _, opt := range opts { + opt(&option) + } + if option.Header == nil { + option.Header = make(http.Header) + } + if shortcutHeaders := cmdutil.ShortcutHeaderOpts(ctx.ctx); shortcutHeaders != nil { + shortcutHeaders(&option) + } + + accessToken, err := ctx.AccessToken() + if err != nil { + cancel() + return nil, err + } + + requestURL, err := buildStreamRequestURL(ctx.Config.Brand, req) + if err != nil { + cancel() + return nil, err + } + bodyReader, contentType, err := buildStreamRequestBody(req.Body) + if err != nil { + cancel() + return nil, err + } + + httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader) + if err != nil { + cancel() + return nil, output.ErrNetwork("stream request failed: %s", err) + } + for key, values := range cmdutil.BaseSecurityHeaders() { + for _, value := range values { + httpReq.Header.Add(key, value) + } + } + for key, values := range option.Header { + for _, value := range values { + httpReq.Header.Add(key, value) + } + } + if contentType != "" { + httpReq.Header.Set("Content-Type", contentType) + } + httpReq.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := streamingClient.Do(httpReq) + if err != nil { + cancel() + return nil, output.ErrNetwork("stream request failed: %s", err) + } + resp.Body = &cancelOnCloseReadCloser{ReadCloser: resp.Body, cancel: cancel} + return resp, nil +} + +func buildStreamRequestURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error) { + requestURL := req.ApiPath + if !strings.HasPrefix(requestURL, "http://") && !strings.HasPrefix(requestURL, "https://") { + var pathSegs []string + for _, segment := range strings.Split(req.ApiPath, "/") { + if !strings.HasPrefix(segment, ":") { + pathSegs = append(pathSegs, segment) + continue + } + pathKey := strings.TrimPrefix(segment, ":") + pathValue, ok := req.PathParams[pathKey] + if !ok { + return "", output.ErrValidation("missing path param %q for %s", pathKey, req.ApiPath) + } + if pathValue == "" { + return "", output.ErrValidation("empty path param %q for %s", pathKey, req.ApiPath) + } + pathSegs = append(pathSegs, url.PathEscape(pathValue)) + } + endpoints := core.ResolveEndpoints(brand) + requestURL = strings.TrimRight(endpoints.Open, "/") + strings.Join(pathSegs, "/") + } + if query := req.QueryParams.Encode(); query != "" { + requestURL += "?" + query + } + return requestURL, nil +} + +func buildStreamRequestBody(body interface{}) (io.Reader, string, error) { + switch typed := body.(type) { + case nil: + return nil, "", nil + case io.Reader: + return typed, "", nil + case []byte: + return bytes.NewReader(typed), "", nil + case string: + return strings.NewReader(typed), "text/plain; charset=utf-8", nil + default: + payload, err := json.Marshal(typed) + if err != nil { + return nil, "", output.Errorf(output.ExitInternal, "api_error", "failed to encode request body: %s", err) + } + return bytes.NewReader(payload), "application/json", nil + } +} + +// DoAPIJSON calls the Lark API via DoAPI, parses the JSON response envelope, +// and returns the "data" field. Suitable for standard JSON APIs (non-file). +func (ctx *RuntimeContext) DoAPIJSON(method, apiPath string, query larkcore.QueryParams, body any) (map[string]any, error) { + req := &larkcore.ApiReq{ + HttpMethod: method, + ApiPath: apiPath, + QueryParams: query, + } + if body != nil { + req.Body = body + } + resp, err := ctx.DoAPI(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + if len(resp.RawBody) > 0 { + var errEnv struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if json.Unmarshal(resp.RawBody, &errEnv) == nil && errEnv.Msg != "" { + return nil, output.ErrAPI(errEnv.Code, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, errEnv.Msg), nil) + } + } + return nil, output.ErrAPI(resp.StatusCode, fmt.Sprintf("HTTP %d", resp.StatusCode), nil) + } + if len(resp.RawBody) == 0 { + return nil, fmt.Errorf("empty response body") + } + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(resp.RawBody, &envelope); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + if envelope.Code != 0 { + return nil, output.ErrAPI(envelope.Code, envelope.Msg, nil) + } + return envelope.Data, nil +} + +// ── IO access ── + +// IO returns the IOStreams from the Factory. +func (ctx *RuntimeContext) IO() *cmdutil.IOStreams { + return ctx.Factory.IOStreams +} + +// ── Output helpers ── + +// Out prints a success JSON envelope to stdout. +func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) { + env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta} + b, _ := json.MarshalIndent(env, "", " ") + fmt.Fprintln(ctx.IO().Out, string(b)) +} + +// OutFormat prints output based on --format flag. +// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue. +func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) { + switch ctx.Format { + case "pretty": + if prettyFn != nil { + prettyFn(ctx.IO().Out) + } else { + ctx.Out(data, meta) + } + case "json", "": + ctx.Out(data, meta) + default: + // table, csv, ndjson — pass data directly; FormatValue handles both + // plain arrays and maps with array fields (e.g. {"members":[…]}) + format, formatOK := output.ParseFormat(ctx.Format) + if !formatOK { + fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format) + } + output.FormatValue(ctx.IO().Out, data, format) + } +} + +// ── Scope pre-check ── + +// checkScopePrereqs performs a fast local check: does the stored token +// contain all scopes declared by the shortcut? Returns the missing ones. +// If no token is stored, returns nil (let the normal auth flow handle it). +func checkScopePrereqs(appID, userOpenId string, required []string) []string { + stored := auth.GetStoredToken(appID, userOpenId) + if stored == nil { + return nil // no token yet — auth flow will catch this later + } + return auth.MissingScopes(stored.Scope, required) +} + +// enhancePermissionError enriches a permission / auth error with the +// shortcut's declared required scopes so the user knows exactly what to do. +func enhancePermissionError(err error, requiredScopes []string) error { + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + return err + } + + // Detect permission-related errors by type or message keywords. + isPermErr := exitErr.Detail.Type == "permission" || exitErr.Detail.Type == "missing_scope" + if !isPermErr { + lower := strings.ToLower(exitErr.Detail.Message) + for _, kw := range []string{"permission", "scope", "authorization", "unauthorized"} { + if strings.Contains(lower, kw) { + isPermErr = true + break + } + } + } + if !isPermErr { + return err + } + + scopeDisplay := strings.Join(requiredScopes, ", ") + scopeArg := strings.Join(requiredScopes, " ") + hint := fmt.Sprintf( + "this command requires scope(s): %s\nrun `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", + scopeDisplay, scopeArg) + // Return a new error instead of mutating the original's Detail in place. + return output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint) +} + +// ── Mounting ── + +// Mount registers the shortcut on a parent command. +func (s Shortcut) Mount(parent *cobra.Command, f *cmdutil.Factory) { + if s.Execute != nil { + s.mountDeclarative(parent, f) + } +} + +func (s Shortcut) mountDeclarative(parent *cobra.Command, f *cmdutil.Factory) { + shortcut := s + if len(shortcut.AuthTypes) == 0 { + shortcut.AuthTypes = []string{"user"} + } + botOnly := len(shortcut.AuthTypes) == 1 && shortcut.AuthTypes[0] == "bot" + + cmd := &cobra.Command{ + Use: shortcut.Command, + Short: shortcut.Description, + RunE: func(cmd *cobra.Command, _ []string) error { + return runShortcut(cmd, f, &shortcut, botOnly) + }, + } + registerShortcutFlags(cmd, &shortcut) + cmdutil.SetTips(cmd, shortcut.Tips) + parent.AddCommand(cmd) +} + +// runShortcut is the execution pipeline for a declarative shortcut. +// Each step is a clear phase: identity → config → scopes → context → validate → execute. +func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error { + as, err := resolveShortcutIdentity(cmd, f, s) + if err != nil { + return err + } + + config, err := f.ResolveConfig(as) + if err != nil { + return err + } + // Identity info is now included in the JSON envelope; skip stderr printing. + // cmdutil.PrintIdentity(f.IOStreams.ErrOut, as, config, false) + + if err := checkShortcutScopes(as, config, s.ScopesForIdentity(string(as))); err != nil { + return err + } + + rctx, err := newRuntimeContext(cmd, f, s, config, as, botOnly) + if err != nil { + return err + } + + if err := validateEnumFlags(rctx, s.Flags); err != nil { + return err + } + if s.Validate != nil { + if err := s.Validate(rctx.ctx, rctx); err != nil { + return err + } + } + + if rctx.Bool("dry-run") { + return handleShortcutDryRun(f, rctx, s) + } + + if s.Risk == "high-risk-write" { + if err := RequireConfirmation(s.Risk, rctx.Bool("yes"), s.Description); err != nil { + return err + } + } + + return s.Execute(rctx.ctx, rctx) +} + +func resolveShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (core.Identity, error) { + // Step 1: determine identity (--as > default-as > auto-detect). + asFlag, _ := cmd.Flags().GetString("as") + as := f.ResolveAs(cmd, core.Identity(asFlag)) + + // Step 2: check if this shortcut supports the resolved identity. + if err := f.CheckIdentity(as, s.AuthTypes); err != nil { + return "", err + } + return as, nil +} + +func checkShortcutScopes(as core.Identity, config *core.CliConfig, scopes []string) error { + if as != core.AsUser || len(scopes) == 0 || config.UserOpenId == "" { + return nil + } + missing := checkScopePrereqs(config.AppID, config.UserOpenId, scopes) + if len(missing) == 0 { + return nil + } + return output.ErrWithHint(output.ExitAuth, "missing_scope", + fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")), + fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " "))) +} + +func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) (*RuntimeContext, error) { + ctx := cmd.Context() + ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String()) + rctx := &RuntimeContext{ctx: ctx, Config: config, Cmd: cmd, botOnly: botOnly, resolvedAs: as, Factory: f} + + sdk, err := f.LarkClient() + if err != nil { + return nil, err + } + rctx.larkSDK = sdk + + if s.HasFormat { + rctx.Format = rctx.Str("format") + } + return rctx, nil +} + +func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error { + for _, fl := range flags { + if len(fl.Enum) == 0 { + continue + } + val := rctx.Str(fl.Name) + if val == "" { + continue + } + valid := false + for _, allowed := range fl.Enum { + if val == allowed { + valid = true + break + } + } + if !valid { + return FlagErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", ")) + } + } + return nil +} + +func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error { + if s.DryRun == nil { + return FlagErrorf("--dry-run is not supported for %s %s", s.Service, s.Command) + } + fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===") + dryResult := s.DryRun(rctx.ctx, rctx) + if rctx.Format == "pretty" { + fmt.Fprint(f.IOStreams.Out, dryResult.Format()) + } else { + output.PrintJson(f.IOStreams.Out, dryResult) + } + return nil +} + +func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) { + for _, fl := range s.Flags { + desc := fl.Desc + if len(fl.Enum) > 0 { + desc += " (" + strings.Join(fl.Enum, "|") + ")" + } + switch fl.Type { + case "bool": + def := fl.Default == "true" + cmd.Flags().Bool(fl.Name, def, desc) + case "int": + var d int + fmt.Sscanf(fl.Default, "%d", &d) + cmd.Flags().Int(fl.Name, d, desc) + case "string_array": + cmd.Flags().StringArray(fl.Name, nil, desc) + default: + cmd.Flags().String(fl.Name, fl.Default, desc) + } + if fl.Hidden { + _ = cmd.Flags().MarkHidden(fl.Name) + } + if fl.Required { + cmd.MarkFlagRequired(fl.Name) + } + if len(fl.Enum) > 0 { + vals := fl.Enum + _ = cmd.RegisterFlagCompletionFunc(fl.Name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return vals, cobra.ShellCompDirectiveNoFileComp + }) + } + } + + cmd.Flags().Bool("dry-run", false, "print request without executing") + if s.HasFormat { + cmd.Flags().String("format", "json", "output format: json (default) | pretty | table | ndjson | csv") + } + if s.Risk == "high-risk-write" { + cmd.Flags().Bool("yes", false, "confirm high-risk operation") + } + cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot") + + _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return s.AuthTypes, cobra.ShellCompDirectiveNoFileComp + }) + if s.HasFormat { + _ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp + }) + } +} diff --git a/shortcuts/common/runner_scope_test.go b/shortcuts/common/runner_scope_test.go new file mode 100644 index 00000000..e7a4efbd --- /dev/null +++ b/shortcuts/common/runner_scope_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/larksuite/cli/internal/output" +) + +func TestEnhancePermissionError_MissingScopeType(t *testing.T) { + scopes := []string{"calendar:calendar:read"} + err := &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "missing_scope", Message: "missing scope"}, + } + got := enhancePermissionError(err, scopes) + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected ExitError, got %T", got) + } + if exitErr.Detail.Hint == "" { + t.Error("expected hint for missing_scope type") + } + if !strings.Contains(exitErr.Detail.Hint, "calendar:calendar:read") { + t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) + } +} + +func TestEnhancePermissionError_KeywordPermission(t *testing.T) { + scopes := []string{"drive:drive:read"} + err := &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "api_error", Message: "Permission denied for resource"}, + } + got := enhancePermissionError(err, scopes) + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected ExitError, got %T", got) + } + if !strings.Contains(exitErr.Detail.Hint, "drive:drive:read") { + t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) + } +} + +func TestEnhancePermissionError_KeywordScope(t *testing.T) { + scopes := []string{"task:task:read"} + err := &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "api_error", Message: "Insufficient scope for operation"}, + } + got := enhancePermissionError(err, scopes) + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected ExitError, got %T", got) + } + if !strings.Contains(exitErr.Detail.Hint, "task:task:read") { + t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) + } +} + +func TestEnhancePermissionError_KeywordAuthorization(t *testing.T) { + scopes := []string{"contact:contact:read"} + err := &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "api_error", Message: "Authorization required"}, + } + got := enhancePermissionError(err, scopes) + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected ExitError, got %T", got) + } + if !strings.Contains(exitErr.Detail.Hint, "contact:contact:read") { + t.Errorf("hint %q missing scope info", exitErr.Detail.Hint) + } +} + +func TestEnhancePermissionError(t *testing.T) { + scopes := []string{"calendar:calendar:read", "drive:drive:read"} + + tests := []struct { + name string + err error + wantHint bool + hintSubstr string + }{ + { + name: "permission type gets enhanced", + err: &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "permission", Message: "no permission"}, + }, + wantHint: true, + hintSubstr: "scope", + }, + { + name: "mcp_error with unauthorized keyword gets enhanced", + err: &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "mcp_error", Message: "request unauthorized by server"}, + }, + wantHint: true, + hintSubstr: "scope", + }, + { + name: "api_error without keyword not modified", + err: &output.ExitError{ + Code: 1, + Detail: &output.ErrDetail{Type: "api_error", Message: "timeout"}, + }, + wantHint: false, + }, + { + name: "plain error not modified", + err: fmt.Errorf("plain error"), + wantHint: false, + }, + { + name: "nil Detail not modified", + err: &output.ExitError{ + Code: 1, + Detail: nil, + }, + wantHint: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := enhancePermissionError(tt.err, scopes) + + if !tt.wantHint { + // Should return original error unchanged + if got != tt.err { + t.Errorf("expected original error returned, got different error: %v", got) + } + return + } + + // Should return an enhanced ExitError with a hint + var exitErr *output.ExitError + if !errors.As(got, &exitErr) { + t.Fatalf("expected ExitError, got %T: %v", got, got) + } + if exitErr.Detail == nil { + t.Fatal("expected Detail to be non-nil") + } + if exitErr.Detail.Hint == "" { + t.Fatal("expected non-empty hint") + } + if !strings.Contains(exitErr.Detail.Hint, tt.hintSubstr) { + t.Errorf("hint %q does not contain %q", exitErr.Detail.Hint, tt.hintSubstr) + } + // Verify the hint includes the actual scopes + for _, s := range scopes { + if !strings.Contains(exitErr.Detail.Hint, s) { + t.Errorf("hint %q does not contain scope %q", exitErr.Detail.Hint, s) + } + } + }) + } +} diff --git a/shortcuts/common/sanitize.go b/shortcuts/common/sanitize.go new file mode 100644 index 00000000..14e00e8d --- /dev/null +++ b/shortcuts/common/sanitize.go @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +// IsDangerousUnicode reports whether r is a Unicode character that can cause +// terminal injection: BiDi overrides, zero-width characters, and Unicode line +// terminators. +func IsDangerousUnicode(r rune) bool { + switch { + case r >= 0x200B && r <= 0x200D: // ZWSP / ZWJ / ZWNJ + return true + case r == 0xFEFF: // BOM / ZWNBSP + return true + case r >= 0x202A && r <= 0x202E: // BiDi: LRE, RLE, PDF, LRO, RLO + return true + case r >= 0x2028 && r <= 0x2029: // LS, PS + return true + case r >= 0x2066 && r <= 0x2069: // LRI, RLI, FSI, PDI + return true + } + return false +} diff --git a/shortcuts/common/testing.go b/shortcuts/common/testing.go new file mode 100644 index 00000000..aca00b00 --- /dev/null +++ b/shortcuts/common/testing.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/core" +) + +// TestNewRuntimeContext creates a RuntimeContext for testing purposes. +// Only Cmd and Config are set; other fields (Factory, larkSDK, etc.) are nil. +func TestNewRuntimeContext(cmd *cobra.Command, cfg *core.CliConfig) *RuntimeContext { + return &RuntimeContext{Cmd: cmd, Config: cfg} +} + +// TestNewRuntimeContextWithCtx creates a RuntimeContext with an explicit context +// for tests that invoke functions which call Ctx() (e.g. HTTP request helpers). +func TestNewRuntimeContextWithCtx(ctx context.Context, cmd *cobra.Command, cfg *core.CliConfig) *RuntimeContext { + return &RuntimeContext{ctx: ctx, Cmd: cmd, Config: cfg} +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go new file mode 100644 index 00000000..70c882cd --- /dev/null +++ b/shortcuts/common/types.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import "context" + +// Flag describes a CLI flag for a shortcut. +type Flag struct { + Name string // flag name (e.g. "calendar-id") + Type string // "string" (default) | "bool" | "int" | "string_array" + Default string // default value as string + Desc string // help text + Hidden bool // hidden from --help, still readable at runtime + Required bool + Enum []string // allowed values (e.g. ["asc", "desc"]); empty means no constraint +} + +// Shortcut represents a high-level CLI command. +type Shortcut struct { + Service string + Command string + Description string + Risk string // "read" | "write" | "high-risk-write" (empty defaults to "read") + Scopes []string // default scopes (fallback when UserScopes/BotScopes are empty) + UserScopes []string // optional: user-identity scopes (overrides Scopes when non-empty) + BotScopes []string // optional: bot-identity scopes (overrides Scopes when non-empty) + + // Declarative fields (new framework). + AuthTypes []string // supported identities: "user", "bot" (default: ["user"]) + Flags []Flag // flag definitions; --dry-run is auto-injected + HasFormat bool // auto-inject --format flag (json|pretty|table|ndjson|csv) + Tips []string // optional tips shown in --help output + + // Business logic hooks. + DryRun func(ctx context.Context, runtime *RuntimeContext) *DryRunAPI // optional: framework prints & returns when --dry-run is set + Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation + Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic +} + +// ScopesForIdentity returns the scopes applicable for the given identity. +// If identity-specific scopes (UserScopes/BotScopes) are set, they take +// precedence over the default Scopes. +func (s *Shortcut) ScopesForIdentity(identity string) []string { + switch identity { + case "user": + if len(s.UserScopes) > 0 { + return s.UserScopes + } + case "bot": + if len(s.BotScopes) > 0 { + return s.BotScopes + } + } + return s.Scopes +} diff --git a/shortcuts/common/types_test.go b/shortcuts/common/types_test.go new file mode 100644 index 00000000..ba067e4c --- /dev/null +++ b/shortcuts/common/types_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "reflect" + "testing" +) + +func TestScopesForIdentity_FallbackToScopes(t *testing.T) { + s := Shortcut{Scopes: []string{"a", "b"}} + for _, id := range []string{"user", "bot", "tenant", ""} { + got := s.ScopesForIdentity(id) + if !reflect.DeepEqual(got, s.Scopes) { + t.Errorf("identity=%q: expected %v, got %v", id, s.Scopes, got) + } + } +} + +func TestScopesForIdentity_UserScopesOverride(t *testing.T) { + s := Shortcut{ + Scopes: []string{"default"}, + UserScopes: []string{"user-only"}, + } + if got := s.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"user-only"}) { + t.Errorf("expected UserScopes, got %v", got) + } + // bot should still fall back + if got := s.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"default"}) { + t.Errorf("expected Scopes fallback for bot, got %v", got) + } +} + +func TestScopesForIdentity_BotScopesOverride(t *testing.T) { + s := Shortcut{ + Scopes: []string{"default"}, + BotScopes: []string{"bot-only"}, + } + if got := s.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"bot-only"}) { + t.Errorf("expected BotScopes, got %v", got) + } + // user should still fall back + if got := s.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"default"}) { + t.Errorf("expected Scopes fallback for user, got %v", got) + } +} + +func TestScopesForIdentity_BothOverrides(t *testing.T) { + s := Shortcut{ + Scopes: []string{"default"}, + UserScopes: []string{"u1", "u2"}, + BotScopes: []string{"b1"}, + } + if got := s.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"u1", "u2"}) { + t.Errorf("expected UserScopes, got %v", got) + } + if got := s.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"b1"}) { + t.Errorf("expected BotScopes, got %v", got) + } + // unknown identity falls back + if got := s.ScopesForIdentity("tenant"); !reflect.DeepEqual(got, []string{"default"}) { + t.Errorf("expected Scopes fallback for tenant, got %v", got) + } +} + +func TestScopesForIdentity_NilScopes(t *testing.T) { + s := Shortcut{} + got := s.ScopesForIdentity("user") + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} diff --git a/shortcuts/common/validate.go b/shortcuts/common/validate.go new file mode 100644 index 00000000..422f99e2 --- /dev/null +++ b/shortcuts/common/validate.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/output" +) + +// FlagErrorf returns a validation error with flag context (exit code 2). +func FlagErrorf(format string, args ...any) error { + return output.ErrValidation(format, args...) +} + +// MutuallyExclusive checks that at most one of the given flags is set. +func MutuallyExclusive(rt *RuntimeContext, flags ...string) error { + var set []string + for _, f := range flags { + val := rt.Str(f) + if val != "" { + set = append(set, "--"+f) + } + } + if len(set) > 1 { + return FlagErrorf("%s are mutually exclusive", strings.Join(set, " and ")) + } + return nil +} + +// AtLeastOne checks that at least one of the given flags is set. +func AtLeastOne(rt *RuntimeContext, flags ...string) error { + for _, f := range flags { + if rt.Str(f) != "" { + return nil + } + } + names := make([]string, len(flags)) + for i, f := range flags { + names[i] = "--" + f + } + return FlagErrorf("specify at least one of %s", strings.Join(names, " or ")) +} + +// ExactlyOne checks that exactly one of the given flags is set. +func ExactlyOne(rt *RuntimeContext, flags ...string) error { + if err := AtLeastOne(rt, flags...); err != nil { + return err + } + return MutuallyExclusive(rt, flags...) +} + +// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal]. +// It returns the parsed value (or defaultVal if the flag is empty) and any validation error. +func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) { + s := rt.Str(flagName) + if s == "" { + return defaultVal, nil + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, FlagErrorf("invalid --%s %q: must be an integer", flagName, s) + } + if n < minVal || n > maxVal { + return 0, FlagErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal) + } + return n, nil +} + +// ParseIntBounded parses an int flag and clamps it to [min, max]. +func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int { + v := rt.Int(name) + if v < min { + return min + } + if v > max { + return max + } + return v +} + +// ValidateSafeOutputDir ensures outputDir is a relative path that resolves +// within the current working directory, preventing path traversal attacks +// (including symlink-based escape). +func ValidateSafeOutputDir(outputDir string) error { + if filepath.IsAbs(outputDir) { + return fmt.Errorf("--output-dir must be a relative path, got: %q", outputDir) + } + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("cannot determine working directory: %w", err) + } + canonicalCwd, err := filepath.EvalSymlinks(cwd) + if err != nil { + canonicalCwd = cwd + } + abs := filepath.Clean(filepath.Join(cwd, outputDir)) + + // Resolve symlinks in abs to prevent symlink-escape attacks (e.g. an + // attacker-controlled symlink inside CWD pointing outside). + canonicalAbs, err := filepath.EvalSymlinks(abs) + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("--output-dir %q: %w", outputDir, err) + } + // Path does not exist yet. If os.Lstat succeeds the entry is a dangling + // symlink — reject it to prevent future escapes once the target is created. + if _, lstErr := os.Lstat(abs); lstErr == nil { + return fmt.Errorf("--output-dir %q is a symlink with a non-existent target", outputDir) + } + // The path itself doesn't exist; the string-level check is sufficient. + canonicalAbs = abs + } + + if !strings.HasPrefix(canonicalAbs, canonicalCwd+string(filepath.Separator)) { + return fmt.Errorf("--output-dir %q resolves outside the working directory", outputDir) + } + return nil +} + +// RejectDangerousChars returns an error if value contains ASCII control +// characters or dangerous Unicode code points. +func RejectDangerousChars(paramName, value string) error { + for _, r := range value { + if r < 0x20 && r != '\t' && r != '\n' { + return fmt.Errorf("parameter %q contains control character U+%04X", paramName, r) + } + if r == 0x7F { + return fmt.Errorf("parameter %q contains DEL character", paramName) + } + if IsDangerousUnicode(r) { + return fmt.Errorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r) + } + } + return nil +} diff --git a/shortcuts/common/validate_ids.go b/shortcuts/common/validate_ids.go new file mode 100644 index 00000000..50697087 --- /dev/null +++ b/shortcuts/common/validate_ids.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "strings" + + "github.com/larksuite/cli/internal/output" +) + +// ValidateChatID checks if a chat ID has valid format (oc_ prefix). +// Also extracts token from URL if provided. +func ValidateChatID(input string) (string, error) { + input = strings.TrimSpace(input) + if input == "" { + return "", output.ErrValidation("chat ID cannot be empty") + } + // Extract from URL if present + if strings.Contains(input, "feishu.cn") || strings.Contains(input, "larksuite.com") { + // Extract oc_xxx from URL + parts := strings.Split(input, "/") + for _, part := range parts { + if strings.HasPrefix(part, "oc_") { + input = part + break + } + } + } + if !strings.HasPrefix(input, "oc_") { + return "", output.ErrValidation("invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)") + } + return input, nil +} + +// ValidateUserID checks if a user ID has valid format (ou_ prefix). +func ValidateUserID(input string) (string, error) { + input = strings.TrimSpace(input) + if input == "" { + return "", output.ErrValidation("user ID cannot be empty") + } + if !strings.HasPrefix(input, "ou_") { + return "", output.ErrValidation("invalid user ID format, should start with 'ou_' (e.g., ou_abc123)") + } + return input, nil +} diff --git a/shortcuts/common/validate_test.go b/shortcuts/common/validate_test.go new file mode 100644 index 00000000..d33d2535 --- /dev/null +++ b/shortcuts/common/validate_test.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" +) + +// newTestRuntime creates a RuntimeContext with string flags for testing. +func newTestRuntime(flags map[string]string) *RuntimeContext { + cmd := &cobra.Command{Use: "test"} + for name := range flags { + cmd.Flags().String(name, "", "") + } + // Parse empty args so flags have defaults, then set values. + cmd.ParseFlags(nil) + for name, val := range flags { + cmd.Flags().Set(name, val) + } + return &RuntimeContext{Cmd: cmd} +} + +func TestMutuallyExclusive(t *testing.T) { + tests := []struct { + name string + flags map[string]string + check []string + wantErr bool + }{ + { + name: "none set", + flags: map[string]string{"a": "", "b": ""}, + check: []string{"a", "b"}, + wantErr: false, + }, + { + name: "one set", + flags: map[string]string{"a": "x", "b": ""}, + check: []string{"a", "b"}, + wantErr: false, + }, + { + name: "both set", + flags: map[string]string{"a": "x", "b": "y"}, + check: []string{"a", "b"}, + wantErr: true, + }, + { + name: "three flags two set", + flags: map[string]string{"a": "x", "b": "", "c": "z"}, + check: []string{"a", "b", "c"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := newTestRuntime(tt.flags) + err := MutuallyExclusive(rt, tt.check...) + if (err != nil) != tt.wantErr { + t.Errorf("MutuallyExclusive() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAtLeastOne(t *testing.T) { + tests := []struct { + name string + flags map[string]string + check []string + wantErr bool + }{ + { + name: "none set", + flags: map[string]string{"a": "", "b": ""}, + check: []string{"a", "b"}, + wantErr: true, + }, + { + name: "one set", + flags: map[string]string{"a": "x", "b": ""}, + check: []string{"a", "b"}, + wantErr: false, + }, + { + name: "both set", + flags: map[string]string{"a": "x", "b": "y"}, + check: []string{"a", "b"}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := newTestRuntime(tt.flags) + err := AtLeastOne(rt, tt.check...) + if (err != nil) != tt.wantErr { + t.Errorf("AtLeastOne() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestExactlyOne(t *testing.T) { + tests := []struct { + name string + flags map[string]string + check []string + wantErr bool + }{ + { + name: "none set", + flags: map[string]string{"a": "", "b": ""}, + check: []string{"a", "b"}, + wantErr: true, + }, + { + name: "one set", + flags: map[string]string{"a": "x", "b": ""}, + check: []string{"a", "b"}, + wantErr: false, + }, + { + name: "both set", + flags: map[string]string{"a": "x", "b": "y"}, + check: []string{"a", "b"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rt := newTestRuntime(tt.flags) + err := ExactlyOne(rt, tt.check...) + if (err != nil) != tt.wantErr { + t.Errorf("ExactlyOne() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestParseIntBounded(t *testing.T) { + tests := []struct { + name string + val string + min, max int + want int + }{ + {"within range", "10", 1, 50, 10}, + {"below min", "0", 1, 50, 1}, + {"above max", "100", 1, 50, 50}, + {"at min", "1", 1, 50, 1}, + {"at max", "50", 1, 50, 50}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int("page-size", 0, "") + cmd.ParseFlags(nil) + cmd.Flags().Set("page-size", tt.val) + rt := &RuntimeContext{Cmd: cmd} + got := ParseIntBounded(rt, "page-size", tt.min, tt.max) + if got != tt.want { + t.Errorf("ParseIntBounded() = %d, want %d", got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// ValidateSafeOutputDir — symlink escape prevention +// --------------------------------------------------------------------------- + +// chdirForTest changes CWD to dir and restores the original CWD on cleanup. +func chdirForTest(t *testing.T, dir string) { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q): %v", dir, err) + } + t.Cleanup(func() { os.Chdir(orig) }) +} + +// TestValidateSafeOutputDir_RejectsSymlinkEscape verifies that a relative path +// that resolves to a symlink pointing outside CWD is rejected. +func TestValidateSafeOutputDir_RejectsSymlinkEscape(t *testing.T) { + outside := t.TempDir() // target outside CWD + workDir := t.TempDir() + chdirForTest(t, workDir) + + // Create a symlink inside CWD pointing to outside. + if err := os.Symlink(outside, filepath.Join(workDir, "evil_out")); err != nil { + t.Fatalf("Symlink: %v", err) + } + + if err := ValidateSafeOutputDir("evil_out"); err == nil { + t.Fatal("expected error for symlink pointing outside CWD, got nil") + } +} + +// TestValidateSafeOutputDir_RejectsDanglingSymlink verifies that a dangling +// symlink (target does not exist) is rejected to prevent future escapes. +func TestValidateSafeOutputDir_RejectsDanglingSymlink(t *testing.T) { + workDir := t.TempDir() + chdirForTest(t, workDir) + + if err := os.Symlink("/nonexistent/outside/target", filepath.Join(workDir, "dangling")); err != nil { + t.Fatalf("Symlink: %v", err) + } + + if err := ValidateSafeOutputDir("dangling"); err == nil { + t.Fatal("expected error for dangling symlink, got nil") + } +} + +// TestValidateSafeOutputDir_AllowsNormalSubdir verifies that an existing real +// subdirectory within CWD is accepted. +func TestValidateSafeOutputDir_AllowsNormalSubdir(t *testing.T) { + workDir := t.TempDir() + chdirForTest(t, workDir) + + subDir := filepath.Join(workDir, "output") + if err := os.Mkdir(subDir, 0700); err != nil { + t.Fatalf("Mkdir: %v", err) + } + + if err := ValidateSafeOutputDir("output"); err != nil { + t.Fatalf("expected no error for real subdir, got: %v", err) + } +} + +// TestValidateSafeOutputDir_AllowsNonExistentPath verifies that a path that +// does not yet exist (new output directory) is accepted. +func TestValidateSafeOutputDir_AllowsNonExistentPath(t *testing.T) { + workDir := t.TempDir() + chdirForTest(t, workDir) + + if err := ValidateSafeOutputDir("new_output_dir"); err != nil { + t.Fatalf("expected no error for non-existent path, got: %v", err) + } +} diff --git a/shortcuts/contact/contact_get_user.go b/shortcuts/contact/contact_get_user.go new file mode 100644 index 00000000..a056a637 --- /dev/null +++ b/shortcuts/contact/contact_get_user.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import ( + "context" + "net/url" + + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var ContactGetUser = common.Shortcut{ + Service: "contact", + Command: "+get-user", + Description: "Get user info (omit user_id for self; provide user_id for specific user)", + Risk: "read", + UserScopes: []string{"contact:user.basic_profile:readonly"}, + BotScopes: []string{"contact:user.base:readonly", "contact:contact.base:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "user-id", Desc: "user ID (omit to get current user)"}, + {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Str("user-id") == "" && runtime.IsBot() { + return common.FlagErrorf("bot identity cannot get current user info, specify --user-id") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + userId := runtime.Str("user-id") + if userId == "" { + return common.NewDryRunAPI(). + GET("/open-apis/authen/v1/user_info"). + Desc("(when --user-id omitted) Get current authenticated user info"). + Set("mode", "current_user") + } + userIdType := runtime.Str("user-id-type") + if userIdType == "" { + userIdType = "open_id" + } + if runtime.IsBot() { + return common.NewDryRunAPI(). + GET("/open-apis/contact/v3/users/:user_id"). + Desc("(bot) Get user info by user ID"). + Params(map[string]interface{}{"user_id_type": userIdType}). + Set("user_id", userId).Set("user_id_type", userIdType) + } + return common.NewDryRunAPI(). + POST("/open-apis/contact/v3/users/basic_batch"). + Desc("(user) Get user basic info by user ID"). + Params(map[string]interface{}{"user_id_type": userIdType}). + Body(map[string]interface{}{"user_ids": []string{userId}}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + userId := runtime.Str("user-id") + userIdType := runtime.Str("user-id-type") + + if userId == "" { + // Current user + data, err := runtime.CallAPI("GET", "/open-apis/authen/v1/user_info", nil, nil) + if err != nil { + return err + } + user := data + if user == nil { + user = make(map[string]interface{}) + } + userData := map[string]interface{}{"user": user} + runtime.OutFormat(userData, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{{ + "name": pickUserName(user), + "open_id": user["open_id"], + "union_id": user["union_id"], + "email": firstNonEmpty(user, "email", "mail"), + "mobile": firstNonEmpty(user, "mobile", "phone"), + "enterprise_email": firstNonEmpty(user, "enterprise_email"), + }}) + }) + return nil + } + + if runtime.IsBot() { + // Bot identity: GET /contact/v3/users/:user_id (full profile) + data, err := runtime.CallAPI("GET", "/open-apis/contact/v3/users/"+url.PathEscape(userId), + map[string]interface{}{"user_id_type": userIdType}, nil) + if err != nil { + return err + } + user, _ := data["user"].(map[string]interface{}) + if user == nil { + user = data + } + userData := map[string]interface{}{"user": user} + runtime.OutFormat(userData, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{{ + "name": pickUserName(user), + "open_id": firstNonEmpty(user, "open_id", "user_id"), + "email": firstNonEmpty(user, "email", "enterprise_email"), + "mobile": firstNonEmpty(user, "mobile", "mobile_phone"), + "department": firstNonEmpty(user, "department_name"), + }}) + }) + return nil + } + + // User identity: POST /contact/v3/users/basic_batch (lightweight) + data, err := runtime.CallAPI("POST", "/open-apis/contact/v3/users/basic_batch", + map[string]interface{}{"user_id_type": userIdType}, + map[string]interface{}{"user_ids": []string{userId}}) + if err != nil { + return err + } + users, _ := data["users"].([]interface{}) + var user map[string]interface{} + if len(users) > 0 { + user, _ = users[0].(map[string]interface{}) + } + if user == nil { + user = make(map[string]interface{}) + } + userData := map[string]interface{}{"user": user} + runtime.OutFormat(userData, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{{ + "name": pickUserName(user), + "user_id": user["user_id"], + }}) + }) + return nil + }, +} diff --git a/shortcuts/contact/contact_search_user.go b/shortcuts/contact/contact_search_user.go new file mode 100644 index 00000000..b6b29058 --- /dev/null +++ b/shortcuts/contact/contact_search_user.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import ( + "context" + "fmt" + "io" + "math" + "strconv" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var ContactSearchUser = common.Shortcut{ + Service: "contact", + Command: "+search-user", + Description: "Search users (results sorted by relevance)", + Risk: "read", + Scopes: []string{"contact:user:search"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "query", Desc: "search keyword", Required: true}, + {Name: "page-size", Default: "20", Desc: "page size"}, + {Name: "page-token", Desc: "page token"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if len(runtime.Str("query")) == 0 { + return common.FlagErrorf("search keyword empty") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + pageSizeStr := runtime.Str("page-size") + pageToken := runtime.Str("page-token") + + pageSize := 20 + if n, err := strconv.Atoi(pageSizeStr); err == nil { + pageSize = int(math.Min(math.Max(float64(n), 1), 200)) + } + + params := map[string]interface{}{ + "query": runtime.Str("query"), + "page_size": pageSize, + } + if pageToken != "" { + params["page_token"] = pageToken + } + + return common.NewDryRunAPI(). + GET("/open-apis/search/v1/user"). + Params(params) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + query := runtime.Str("query") + pageSizeStr := runtime.Str("page-size") + pageToken := runtime.Str("page-token") + + pageSize := 20 + if n, err := strconv.Atoi(pageSizeStr); err == nil { + pageSize = int(math.Min(math.Max(float64(n), 1), 200)) + } + + params := map[string]interface{}{ + "query": query, + "page_size": pageSize, + } + if pageToken != "" { + params["page_token"] = pageToken + } + + data, err := runtime.CallAPI("GET", "/open-apis/search/v1/user", params, nil) + if err != nil { + return err + } + users, _ := data["users"].([]interface{}) + + for _, u := range users { + if m, _ := u.(map[string]interface{}); m != nil { + if av, _ := m["avatar"].(map[string]interface{}); av != nil { + m["avatar"] = map[string]interface{}{"avatar_origin": av["avatar_origin"]} + } + } + } + searchData := map[string]interface{}{ + "users": users, + "has_more": data["has_more"], + "page_token": data["page_token"], + } + runtime.OutFormat(searchData, nil, func(w io.Writer) { + if len(users) == 0 { + fmt.Fprintln(w, "No matching users found.") + return + } + + var rows []map[string]interface{} + for _, u := range users { + m, _ := u.(map[string]interface{}) + rows = append(rows, map[string]interface{}{ + "name": pickUserName(m), + "open_id": m["open_id"], + "email": firstNonEmpty(m, "email", "mail"), + "mobile": firstNonEmpty(m, "mobile", "phone"), + "department": firstNonEmpty(m, "department_name", "department"), + "enterprise_email": firstNonEmpty(m, "enterprise_email"), + }) + } + output.PrintTable(w, rows) + hasMore, _ := data["has_more"].(bool) + moreHint := "" + if hasMore { + pt, _ := data["page_token"].(string) + moreHint = fmt.Sprintf(" (more available, page_token: %s)", pt) + } + fmt.Fprintf(w, "\n%d user(s)%s\n", len(users), moreHint) + }) + return nil + }, +} + +func pickUserName(m map[string]interface{}) string { + for _, key := range []string{"name", "user_name", "display_name", "employee_name", "cn_name"} { + if v, ok := m[key].(string); ok && v != "" { + return v + } + } + return "" +} + +func firstNonEmpty(m map[string]interface{}, keys ...string) string { + for _, key := range keys { + if v, ok := m[key].(string); ok && v != "" { + return v + } + } + return "" +} diff --git a/shortcuts/contact/shortcuts.go b/shortcuts/contact/shortcuts.go new file mode 100644 index 00000000..ace3b0fa --- /dev/null +++ b/shortcuts/contact/shortcuts.go @@ -0,0 +1,14 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package contact + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all contact shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + ContactSearchUser, + ContactGetUser, + } +} diff --git a/shortcuts/doc/doc_media_download.go b/shortcuts/doc/doc_media_download.go new file mode 100644 index 00000000..80b7c88f --- /dev/null +++ b/shortcuts/doc/doc_media_download.go @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var mimeToExt = map[string]string{ + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + "application/pdf": ".pdf", + "video/mp4": ".mp4", + "text/plain": ".txt", +} + +var DocMediaDownload = common.Shortcut{ + Service: "docs", + Command: "+media-download", + Description: "Download document media or whiteboard thumbnail (auto-detects extension)", + Risk: "read", + Scopes: []string{"docs:document.media:download"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "token", Desc: "resource token (file_token or whiteboard_id)", Required: true}, + {Name: "output", Desc: "local save path", Required: true}, + {Name: "type", Default: "media", Desc: "resource type: media (default) | whiteboard"}, + {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token := runtime.Str("token") + outputPath := runtime.Str("output") + mediaType := runtime.Str("type") + if mediaType == "whiteboard" { + return common.NewDryRunAPI(). + GET("/open-apis/board/v1/whiteboards/:token/download_as_image"). + Desc("(when --type=whiteboard) Download whiteboard as image"). + Set("token", token).Set("output", outputPath) + } + return common.NewDryRunAPI(). + GET("/open-apis/drive/v1/medias/:token/download"). + Desc("(when --type=media) Download document media file"). + Set("token", token).Set("output", outputPath) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token := runtime.Str("token") + outputPath := runtime.Str("output") + mediaType := runtime.Str("type") + overwrite := runtime.Bool("overwrite") + + if err := validate.ResourceName(token, "--token"); err != nil { + return output.ErrValidation("%s", err) + } + // Early path validation before API call (final validation after auto-extension below) + if _, err := validate.SafeOutputPath(outputPath); err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s %s\n", mediaType, common.MaskToken(token)) + + // Build API URL + encodedToken := validate.EncodePathSegment(token) + var apiPath string + if mediaType == "whiteboard" { + apiPath = fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", encodedToken) + } else { + apiPath = fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", encodedToken) + } + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: apiPath, + }, larkcore.WithFileDownload()) + if err != nil { + return output.ErrNetwork("download failed: %v", err) + } + if apiResp.StatusCode >= 400 { + return output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, strings.TrimSpace(string(apiResp.RawBody))) + } + + // Auto-detect extension from Content-Type + finalPath := outputPath + currentExt := filepath.Ext(outputPath) + if currentExt == "" { + contentType := apiResp.Header.Get("Content-Type") + mimeType := strings.Split(contentType, ";")[0] + mimeType = strings.TrimSpace(mimeType) + if ext, ok := mimeToExt[mimeType]; ok { + finalPath = outputPath + ext + } else if mediaType == "whiteboard" { + finalPath = outputPath + ".png" + } + } + + safePath, err := validate.SafeOutputPath(finalPath) + if err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + if err := common.EnsureWritableFile(safePath, overwrite); err != nil { + return err + } + + os.MkdirAll(filepath.Dir(safePath), 0755) + if err := validate.AtomicWrite(safePath, apiResp.RawBody, 0644); err != nil { + return output.Errorf(output.ExitInternal, "io", "cannot create file: %v", err) + } + + runtime.Out(map[string]interface{}{ + "saved_path": safePath, + "size_bytes": len(apiResp.RawBody), + "content_type": apiResp.Header.Get("Content-Type"), + }, nil) + return nil + }, +} diff --git a/shortcuts/doc/doc_media_insert.go b/shortcuts/doc/doc_media_insert.go new file mode 100644 index 00000000..32986f74 --- /dev/null +++ b/shortcuts/doc/doc_media_insert.go @@ -0,0 +1,422 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const maxFileSize = 20 * 1024 * 1024 // 20MB + +var alignMap = map[string]int{ + "left": 1, + "center": 2, + "right": 3, +} + +var DocMediaInsert = common.Shortcut{ + Service: "docs", + Command: "+media-insert", + Description: "Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback)", + Risk: "write", + Scopes: []string{"docs:document.media:upload", "docx:document:write_only", "docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file", Desc: "local file path (max 20MB)", Required: true}, + {Name: "doc", Desc: "document URL or document_id", Required: true}, + {Name: "type", Default: "image", Desc: "type: image | file"}, + {Name: "align", Desc: "alignment: left | center | right"}, + {Name: "caption", Desc: "image caption text"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + docRef, err := parseDocumentRef(runtime.Str("doc")) + if err != nil { + return err + } + if docRef.Kind == "doc" { + return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + docRef, err := parseDocumentRef(runtime.Str("doc")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + + documentID := docRef.Token + stepBase := 1 + filePath := runtime.Str("file") + mediaType := runtime.Str("type") + caption := runtime.Str("caption") + + parentType := parentTypeForMediaType(mediaType) + createBlockData := buildCreateBlockData(mediaType, 0) + createBlockData["index"] = "" + batchUpdateData := buildBatchUpdateData("", mediaType, "", runtime.Str("align"), caption) + + d := common.NewDryRunAPI() + if docRef.Kind == "wiki" { + documentID = "" + stepBase = 2 + d.Desc("5-step orchestration: resolve wiki → query root → create block → upload file → bind to block (auto-rollback on failure)"). + GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to docx document"). + Params(map[string]interface{}{"token": docRef.Token}) + } else { + d.Desc("4-step orchestration: query root → create block → upload file → bind to block (auto-rollback on failure)") + } + + d. + GET("/open-apis/docx/v1/documents/:document_id/blocks/:document_id"). + Desc(fmt.Sprintf("[%d] Get document root block", stepBase)). + POST("/open-apis/docx/v1/documents/:document_id/blocks/:document_id/children"). + Desc(fmt.Sprintf("[%d] Create empty block at document end", stepBase+1)). + Body(createBlockData). + POST("/open-apis/drive/v1/medias/upload_all"). + Desc(fmt.Sprintf("[%d] Upload local file (multipart/form-data)", stepBase+2)). + Body(map[string]interface{}{ + "file_name": filepath.Base(filePath), + "parent_type": parentType, + "parent_node": "", + "file": "@" + filePath, + }). + PATCH("/open-apis/docx/v1/documents/:document_id/blocks/batch_update"). + Desc(fmt.Sprintf("[%d] Bind uploaded file token to the new block", stepBase+3)). + Body(batchUpdateData) + + return d.Set("document_id", documentID) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + filePath := runtime.Str("file") + docInput := runtime.Str("doc") + mediaType := runtime.Str("type") + alignStr := runtime.Str("align") + caption := runtime.Str("caption") + + safeFilePath, pathErr := validate.SafeInputPath(filePath) + if pathErr != nil { + return output.ErrValidation("unsafe file path: %s", pathErr) + } + filePath = safeFilePath + + documentID, err := resolveDocxDocumentID(runtime, docInput) + if err != nil { + return err + } + + // Validate file + stat, err := os.Stat(filePath) + if err != nil { + return output.ErrValidation("file not found: %s", filePath) + } + if stat.Size() > maxFileSize { + return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024) + } + + fileName := filepath.Base(filePath) + fmt.Fprintf(runtime.IO().ErrOut, "Inserting: %s -> document %s\n", fileName, common.MaskToken(documentID)) + + // Step 1: Get document root block to find where to insert + rootData, err := runtime.CallAPI("GET", + fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentID), validate.EncodePathSegment(documentID)), + nil, nil) + if err != nil { + return err + } + + parentBlockID, insertIndex, err := extractAppendTarget(rootData, documentID) + if err != nil { + return err + } + fmt.Fprintf(runtime.IO().ErrOut, "Root block ready: %s (%d children)\n", parentBlockID, insertIndex) + + // Step 2: Create an empty block at the end of the document + fmt.Fprintf(runtime.IO().ErrOut, "Creating block at index %d\n", insertIndex) + + createData, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)), + nil, buildCreateBlockData(mediaType, insertIndex)) + if err != nil { + return err + } + + blockId, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, mediaType) + + if blockId == "" { + return output.Errorf(output.ExitAPI, "api_error", "failed to create block: no block_id returned") + } + + fmt.Fprintf(runtime.IO().ErrOut, "Block created: %s\n", blockId) + if uploadParentNode != blockId || replaceBlockID != blockId { + fmt.Fprintf(runtime.IO().ErrOut, "Resolved file block targets: upload=%s replace=%s\n", uploadParentNode, replaceBlockID) + } + + // Rollback helper + rollback := func() error { + fmt.Fprintf(runtime.IO().ErrOut, "Rolling back: deleting block %s\n", blockId) + _, err := runtime.CallAPI("DELETE", + fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children/batch_delete", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)), + nil, buildDeleteBlockData(insertIndex)) + return err + } + withRollbackWarning := func(opErr error) error { + rollbackErr := rollback() + if rollbackErr == nil { + return opErr + } + warning := fmt.Sprintf("rollback failed for block %s: %v", blockId, rollbackErr) + fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning) + return opErr + } + + // Step 3: Upload media file + fileToken, err := uploadMediaFile(ctx, runtime, filePath, fileName, mediaType, uploadParentNode, documentID) + if err != nil { + return withRollbackWarning(err) + } + + fmt.Fprintf(runtime.IO().ErrOut, "File uploaded: %s\n", fileToken) + + // Step 4: Bind file token to block via batch_update + fmt.Fprintf(runtime.IO().ErrOut, "Binding uploaded media to block %s\n", replaceBlockID) + + if _, err := runtime.CallAPI("PATCH", + fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentID)), + nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption)); err != nil { + return withRollbackWarning(err) + } + + runtime.Out(map[string]interface{}{ + "document_id": documentID, + "block_id": blockId, + "file_token": fileToken, + "type": mediaType, + }, nil) + return nil + }, +} + +func blockTypeForMediaType(mediaType string) int { + if mediaType == "file" { + return 23 + } + return 27 +} + +func parentTypeForMediaType(mediaType string) string { + if mediaType == "file" { + return "docx_file" + } + return "docx_image" +} + +func buildCreateBlockData(mediaType string, index int) map[string]interface{} { + child := map[string]interface{}{ + "block_type": blockTypeForMediaType(mediaType), + } + if mediaType == "file" { + child["file"] = map[string]interface{}{} + } else { + child["image"] = map[string]interface{}{} + } + return map[string]interface{}{ + "children": []interface{}{ + child, + }, + "index": index, + } +} + +func buildDeleteBlockData(index int) map[string]interface{} { + return map[string]interface{}{ + "start_index": index, + "end_index": index + 1, + } +} + +func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string, error) { + docRef, err := parseDocumentRef(input) + if err != nil { + return "", err + } + + switch docRef.Kind { + case "docx": + return docRef.Token, nil + case "doc": + return "", output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx") + case "wiki": + fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token)) + data, err := runtime.CallAPI( + "GET", + "/open-apis/wiki/v2/spaces/get_node", + map[string]interface{}{"token": docRef.Token}, + nil, + ) + if err != nil { + return "", err + } + + node := common.GetMap(data, "node") + objType := common.GetString(node, "obj_type") + objToken := common.GetString(node, "obj_token") + if objType == "" || objToken == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data") + } + if objType != "docx" { + return "", output.ErrValidation("wiki resolved to %q, but docs +media-insert only supports docx documents", objType) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to docx: %s\n", common.MaskToken(objToken)) + return objToken, nil + default: + return "", output.ErrValidation("docs +media-insert only supports docx documents") + } +} + +func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption string) map[string]interface{} { + request := map[string]interface{}{ + "block_id": blockID, + } + if mediaType == "file" { + request["replace_file"] = map[string]interface{}{ + "token": fileToken, + } + } else { + replaceImage := map[string]interface{}{ + "token": fileToken, + } + if alignVal, ok := alignMap[alignStr]; ok { + replaceImage["align"] = alignVal + } + if caption != "" { + replaceImage["caption"] = map[string]interface{}{ + "content": caption, + } + } + request["replace_image"] = replaceImage + } + return map[string]interface{}{ + "requests": []interface{}{request}, + } +} + +func extractAppendTarget(rootData map[string]interface{}, fallbackBlockID string) (string, int, error) { + block, _ := rootData["block"].(map[string]interface{}) + if len(block) == 0 { + return "", 0, output.Errorf(output.ExitAPI, "api_error", "failed to query document root block") + } + + parentBlockID := fallbackBlockID + if blockID, _ := block["block_id"].(string); blockID != "" { + parentBlockID = blockID + } + + children, _ := block["children"].([]interface{}) + return parentBlockID, len(children), nil +} + +func extractCreatedBlockTargets(createData map[string]interface{}, mediaType string) (blockID, uploadParentNode, replaceBlockID string) { + children, _ := createData["children"].([]interface{}) + if len(children) == 0 { + return "", "", "" + } + + child, _ := children[0].(map[string]interface{}) + blockID, _ = child["block_id"].(string) + uploadParentNode = blockID + replaceBlockID = blockID + + if mediaType != "file" { + return blockID, uploadParentNode, replaceBlockID + } + + nestedChildren, _ := child["children"].([]interface{}) + if len(nestedChildren) == 0 { + return blockID, uploadParentNode, replaceBlockID + } + if nestedBlockID, ok := nestedChildren[0].(string); ok && nestedBlockID != "" { + uploadParentNode = nestedBlockID + replaceBlockID = nestedBlockID + } + return blockID, uploadParentNode, replaceBlockID +} + +// uploadMediaFile uploads a file to Feishu drive as media. +func uploadMediaFile(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, mediaType, parentNode, docId string) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return "", output.Errorf(output.ExitInternal, "internal_error", "failed to stat file: %v", err) + } + fileSize := stat.Size() + + parentType := parentTypeForMediaType(mediaType) + + // Build SDK Formdata + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddField("parent_type", parentType) + fd.AddField("parent_node", parentNode) + fd.AddField("size", fmt.Sprintf("%d", fileSize)) + if docId != "" { + extra, err := buildDriveRouteExtra(docId) + if err != nil { + return "", err + } + fd.AddField("extra", extra) + } + fd.AddFile("file", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/medias/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return "", err + } + return "", output.ErrNetwork("file upload failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return "", output.Errorf(output.ExitAPI, "api_error", "file upload failed: invalid response JSON: %v", err) + } + + code, _ := util.ToFloat64(result["code"]) + if code != 0 { + msg, _ := result["msg"].(string) + return "", output.ErrAPI(int(code), fmt.Sprintf("file upload failed: [%d] %s", int(code), msg), result["error"]) + } + + data, _ := result["data"].(map[string]interface{}) + fileToken, _ := data["file_token"].(string) + if fileToken == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "file upload failed: no file_token returned") + } + + return fileToken, nil +} diff --git a/shortcuts/doc/doc_media_insert_test.go b/shortcuts/doc/doc_media_insert_test.go new file mode 100644 index 00000000..0e4f9bad --- /dev/null +++ b/shortcuts/doc/doc_media_insert_test.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "reflect" + "testing" +) + +func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) { + t.Parallel() + + got := buildCreateBlockData("image", 3) + want := map[string]interface{}{ + "children": []interface{}{ + map[string]interface{}{ + "block_type": 27, + "image": map[string]interface{}{}, + }, + }, + "index": 3, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildCreateBlockData() = %#v, want %#v", got, want) + } +} + +func TestBuildCreateBlockDataForFileIncludesFilePayload(t *testing.T) { + t.Parallel() + + got := buildCreateBlockData("file", 1) + want := map[string]interface{}{ + "children": []interface{}{ + map[string]interface{}{ + "block_type": 23, + "file": map[string]interface{}{}, + }, + }, + "index": 1, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildCreateBlockData(file) = %#v, want %#v", got, want) + } +} + +func TestBuildDeleteBlockDataUsesHalfOpenInterval(t *testing.T) { + t.Parallel() + + got := buildDeleteBlockData(5) + want := map[string]interface{}{ + "start_index": 5, + "end_index": 6, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildDeleteBlockData() = %#v, want %#v", got, want) + } +} + +func TestBuildBatchUpdateDataForImage(t *testing.T) { + t.Parallel() + + got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text") + want := map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "block_id": "blk_1", + "replace_image": map[string]interface{}{ + "token": "file_tok", + "align": 2, + "caption": map[string]interface{}{ + "content": "caption text", + }, + }, + }, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildBatchUpdateData(image) = %#v, want %#v", got, want) + } +} + +func TestBuildBatchUpdateDataForFile(t *testing.T) { + t.Parallel() + + got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "") + want := map[string]interface{}{ + "requests": []interface{}{ + map[string]interface{}{ + "block_id": "blk_2", + "replace_file": map[string]interface{}{ + "token": "file_tok", + }, + }, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildBatchUpdateData(file) = %#v, want %#v", got, want) + } +} + +func TestExtractAppendTargetUsesRootChildrenCount(t *testing.T) { + t.Parallel() + + rootData := map[string]interface{}{ + "block": map[string]interface{}{ + "block_id": "root_block", + "children": []interface{}{"c1", "c2", "c3"}, + }, + } + + blockID, index, err := extractAppendTarget(rootData, "fallback") + if err != nil { + t.Fatalf("extractAppendTarget() unexpected error: %v", err) + } + if blockID != "root_block" { + t.Fatalf("extractAppendTarget() blockID = %q, want %q", blockID, "root_block") + } + if index != 3 { + t.Fatalf("extractAppendTarget() index = %d, want 3", index) + } +} + +func TestExtractCreatedBlockTargetsForImage(t *testing.T) { + t.Parallel() + + createData := map[string]interface{}{ + "children": []interface{}{ + map[string]interface{}{ + "block_id": "img_outer", + }, + }, + } + + blockID, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, "image") + if blockID != "img_outer" || uploadParentNode != "img_outer" || replaceBlockID != "img_outer" { + t.Fatalf("extractCreatedBlockTargets(image) = (%q, %q, %q)", blockID, uploadParentNode, replaceBlockID) + } +} + +func TestExtractCreatedBlockTargetsForFileUsesNestedFileBlock(t *testing.T) { + t.Parallel() + + createData := map[string]interface{}{ + "children": []interface{}{ + map[string]interface{}{ + "block_id": "view_outer", + "children": []interface{}{"file_inner"}, + }, + }, + } + + blockID, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, "file") + if blockID != "view_outer" { + t.Fatalf("extractCreatedBlockTargets(file) blockID = %q, want %q", blockID, "view_outer") + } + if uploadParentNode != "file_inner" { + t.Fatalf("extractCreatedBlockTargets(file) uploadParentNode = %q, want %q", uploadParentNode, "file_inner") + } + if replaceBlockID != "file_inner" { + t.Fatalf("extractCreatedBlockTargets(file) replaceBlockID = %q, want %q", replaceBlockID, "file_inner") + } +} diff --git a/shortcuts/doc/doc_media_test.go b/shortcuts/doc/doc_media_test.go new file mode 100644 index 00000000..7e805213 --- /dev/null +++ b/shortcuts/doc/doc_media_test.go @@ -0,0 +1,209 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "bytes" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func docsTestConfig() *core.CliConfig { + return docsTestConfigWithAppID("docs-test-app") +} + +func docsTestConfigWithAppID(appID string) *core.CliConfig { + return &core.CliConfig{ + AppID: appID, AppSecret: "test-secret", Brand: core.BrandFeishu, + } +} + +func mountAndRunDocs(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "docs"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func withDocsWorkingDir(t *testing.T, dir string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) error: %v", dir, err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatalf("restore cwd error: %v", err) + } + }) +} + +func registerDocsBotTokenStub(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token", "expire": 7200, + }, + }) +} + +func TestDocMediaInsertRejectsOldDocURL(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, docsTestConfig()) + + err := mountAndRunDocs(t, DocMediaInsert, []string{ + "+media-insert", + "--doc", "https://example.larksuite.com/doc/xxxxxx", + "--file", "dummy.png", + "--dry-run", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), "only supports docx documents") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDocMediaInsertDryRunWikiAddsResolveStep(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfig()) + + err := mountAndRunDocs(t, DocMediaInsert, []string{ + "+media-insert", + "--doc", "https://example.larksuite.com/wiki/xxxxxx", + "--file", "dummy.png", + "--dry-run", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "Resolve wiki node to docx document") { + t.Fatalf("dry-run output missing wiki resolve step: %s", out) + } + if !strings.Contains(out, "resolved_docx_token") { + t.Fatalf("dry-run output missing resolved docx token placeholder: %s", out) + } +} + +func TestDocMediaInsertExecuteResolvesWikiBeforeFileCheck(t *testing.T) { + f, _, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-insert-exec-app")) + registerDocsBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "docx", + "obj_token": "doxcnResolved123", + }, + }, + }, + }) + + tmpDir := t.TempDir() + withDocsWorkingDir(t, tmpDir) + + err := mountAndRunDocs(t, DocMediaInsert, []string{ + "+media-insert", + "--doc", "https://example.larksuite.com/wiki/xxxxxx", + "--file", "missing.png", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected file-not-found error, got nil") + } + if !strings.Contains(err.Error(), "file not found") { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stderr.String(), "Resolved wiki to docx") { + t.Fatalf("stderr missing wiki resolution log: %s", stderr.String()) + } +} + +func TestDocMediaDownloadRejectsOverwriteWithoutFlag(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-overwrite-app")) + registerDocsBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/medias/tok_123/download", + Status: 200, + Body: []byte("new"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + tmpDir := t.TempDir() + withDocsWorkingDir(t, tmpDir) + if err := os.WriteFile("download.bin", []byte("old"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDocs(t, DocMediaDownload, []string{ + "+media-download", + "--token", "tok_123", + "--output", "download.bin", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected overwrite protection error, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDocMediaDownloadRejectsHTTPErrorBeforeWrite(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-download-app")) + registerDocsBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/medias/tok_123/download", + Status: 404, + Body: "not found", + Headers: http.Header{"Content-Type": []string{"text/plain"}}, + }) + + tmpDir := t.TempDir() + withDocsWorkingDir(t, tmpDir) + + err := mountAndRunDocs(t, DocMediaDownload, []string{ + "+media-download", + "--token", "tok_123", + "--output", "download.bin", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected HTTP error, got nil") + } + if !strings.Contains(err.Error(), "HTTP 404") { + t.Fatalf("unexpected error: %v", err) + } + if _, statErr := os.Stat(filepath.Join(tmpDir, "download.bin")); !os.IsNotExist(statErr) { + t.Fatalf("download target should not be created, statErr=%v", statErr) + } +} diff --git a/shortcuts/doc/doc_media_upload.go b/shortcuts/doc/doc_media_upload.go new file mode 100644 index 00000000..008597b8 --- /dev/null +++ b/shortcuts/doc/doc_media_upload.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var MediaUpload = common.Shortcut{ + Service: "docs", + Command: "+media-upload", + Description: "Upload media file (image/attachment) to a document block", + Risk: "write", + Scopes: []string{"docs:document.media:upload"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file", Desc: "local file path (max 20MB)", Required: true}, + {Name: "parent-type", Desc: "parent type: docx_image | docx_file", Required: true}, + {Name: "parent-node", Desc: "parent node ID (block_id)", Required: true}, + {Name: "doc-id", Desc: "document ID (for drive_route_token)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + filePath := runtime.Str("file") + parentType := runtime.Str("parent-type") + parentNode := runtime.Str("parent-node") + docId := runtime.Str("doc-id") + body := map[string]interface{}{ + "file_name": filepath.Base(filePath), + "parent_type": parentType, + "parent_node": parentNode, + "file": "@" + filePath, + } + if docId != "" { + body["extra"] = fmt.Sprintf(`{"drive_route_token":"%s"}`, docId) + } + return common.NewDryRunAPI(). + Desc("multipart/form-data upload"). + POST("/open-apis/drive/v1/medias/upload_all"). + Body(body) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + filePath := runtime.Str("file") + parentType := runtime.Str("parent-type") + parentNode := runtime.Str("parent-node") + docId := runtime.Str("doc-id") + + safeFilePath, pathErr := validate.SafeInputPath(filePath) + if pathErr != nil { + return output.ErrValidation("unsafe file path: %s", pathErr) + } + filePath = safeFilePath + + // Validate file + stat, err := os.Stat(filePath) + if err != nil { + return output.ErrValidation("file not found: %s", filePath) + } + if stat.Size() > maxFileSize { + return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(stat.Size())/1024/1024) + } + + fileName := filepath.Base(filePath) + fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%d bytes)\n", fileName, stat.Size()) + + f, err := os.Open(filePath) + if err != nil { + return output.ErrValidation("cannot open file: %v", err) + } + defer f.Close() + + // Build SDK Formdata + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddField("parent_type", parentType) + fd.AddField("parent_node", parentNode) + fd.AddField("size", fmt.Sprintf("%d", stat.Size())) + if docId != "" { + extra, err := buildDriveRouteExtra(docId) + if err != nil { + return err + } + fd.AddField("extra", extra) + } + fd.AddFile("file", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/medias/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return err + } + return output.ErrNetwork("upload failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) + } + + code, _ := util.ToFloat64(result["code"]) + if code != 0 { + msg, _ := result["msg"].(string) + return output.ErrAPI(int(code), fmt.Sprintf("upload failed: [%d] %s", int(code), msg), result["error"]) + } + + data, _ := result["data"].(map[string]interface{}) + fileToken, _ := data["file_token"].(string) + if fileToken == "" { + return output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + } + + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "file_name": fileName, + "size": stat.Size(), + }, nil) + return nil + }, +} diff --git a/shortcuts/doc/docs_create.go b/shortcuts/doc/docs_create.go new file mode 100644 index 00000000..c88c6eaf --- /dev/null +++ b/shortcuts/doc/docs_create.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var DocsCreate = common.Shortcut{ + Service: "docs", + Command: "+create", + Description: "Create a Lark document", + Risk: "write", + AuthTypes: []string{"user", "bot"}, + Scopes: []string{"docx:document:create"}, + Flags: []common.Flag{ + {Name: "title", Desc: "document title"}, + {Name: "markdown", Desc: "Markdown content (Lark-flavored)", Required: true}, + {Name: "folder-token", Desc: "parent folder token"}, + {Name: "wiki-node", Desc: "wiki node token"}, + {Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + count := 0 + if runtime.Str("folder-token") != "" { + count++ + } + if runtime.Str("wiki-node") != "" { + count++ + } + if runtime.Str("wiki-space") != "" { + count++ + } + if count > 1 { + return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + args := map[string]interface{}{ + "markdown": runtime.Str("markdown"), + } + if v := runtime.Str("title"); v != "" { + args["title"] = v + } + if v := runtime.Str("folder-token"); v != "" { + args["folder_token"] = v + } + if v := runtime.Str("wiki-node"); v != "" { + args["wiki_node"] = v + } + if v := runtime.Str("wiki-space"); v != "" { + args["wiki_space"] = v + } + return common.NewDryRunAPI(). + POST(common.MCPEndpoint(runtime.Config.Brand)). + Desc("MCP tool: create-doc"). + Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}). + Set("mcp_tool", "create-doc").Set("args", args) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + args := map[string]interface{}{ + "markdown": runtime.Str("markdown"), + } + if v := runtime.Str("title"); v != "" { + args["title"] = v + } + if v := runtime.Str("folder-token"); v != "" { + args["folder_token"] = v + } + if v := runtime.Str("wiki-node"); v != "" { + args["wiki_node"] = v + } + if v := runtime.Str("wiki-space"); v != "" { + args["wiki_space"] = v + } + + result, err := common.CallMCPTool(runtime, "create-doc", args) + if err != nil { + return err + } + + runtime.Out(result, nil) + return nil + }, +} diff --git a/shortcuts/doc/docs_fetch.go b/shortcuts/doc/docs_fetch.go new file mode 100644 index 00000000..65a4e890 --- /dev/null +++ b/shortcuts/doc/docs_fetch.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "fmt" + "io" + "strconv" + + "github.com/larksuite/cli/shortcuts/common" +) + +var DocsFetch = common.Shortcut{ + Service: "docs", + Command: "+fetch", + Description: "Fetch Lark document content", + Risk: "read", + Scopes: []string{"docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL or token", Required: true}, + {Name: "offset", Desc: "pagination offset"}, + {Name: "limit", Desc: "pagination limit"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + args := map[string]interface{}{ + "doc_id": runtime.Str("doc"), + } + if v := runtime.Str("offset"); v != "" { + n, _ := strconv.Atoi(v) + args["offset"] = n + } + if v := runtime.Str("limit"); v != "" { + n, _ := strconv.Atoi(v) + args["limit"] = n + } + return common.NewDryRunAPI(). + POST(common.MCPEndpoint(runtime.Config.Brand)). + Desc("MCP tool: fetch-doc"). + Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}). + Set("mcp_tool", "fetch-doc").Set("args", args) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + args := map[string]interface{}{ + "doc_id": runtime.Str("doc"), + } + if v := runtime.Str("offset"); v != "" { + n, _ := strconv.Atoi(v) + args["offset"] = n + } + if v := runtime.Str("limit"); v != "" { + n, _ := strconv.Atoi(v) + args["limit"] = n + } + + result, err := common.CallMCPTool(runtime, "fetch-doc", args) + if err != nil { + return err + } + + runtime.OutFormat(result, nil, func(w io.Writer) { + if title, ok := result["title"].(string); ok && title != "" { + fmt.Fprintf(w, "# %s\n\n", title) + } + if md, ok := result["markdown"].(string); ok { + fmt.Fprintln(w, md) + } + if hasMore, ok := result["has_more"].(bool); ok && hasMore { + fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---") + } + }) + return nil + }, +} diff --git a/shortcuts/doc/docs_search.go b/shortcuts/doc/docs_search.go new file mode 100644 index 00000000..9824c3f1 --- /dev/null +++ b/shortcuts/doc/docs_search.go @@ -0,0 +1,306 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "regexp" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +var DocsSearch = common.Shortcut{ + Service: "docs", + Command: "+search", + Description: "Search Lark docs, Wiki, and spreadsheet files (Search v2: doc_wiki/search)", + Risk: "read", + Scopes: []string{"search:docs:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "query", Desc: "search keyword"}, + {Name: "filter", Desc: "filter conditions (JSON object)"}, + {Name: "page-token", Desc: "page token"}, + {Name: "page-size", Default: "15", Desc: "page size (default 15, max 20)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + requestData, err := buildDocsSearchRequest( + runtime.Str("query"), + runtime.Str("filter"), + runtime.Str("page-token"), + runtime.Str("page-size"), + ) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + + return common.NewDryRunAPI(). + POST("/open-apis/search/v2/doc_wiki/search"). + Body(requestData) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + requestData, err := buildDocsSearchRequest( + runtime.Str("query"), + runtime.Str("filter"), + runtime.Str("page-token"), + runtime.Str("page-size"), + ) + if err != nil { + return err + } + + data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData) + if err != nil { + return err + } + items, _ := data["res_units"].([]interface{}) + + // Add ISO time fields + normalizedItems := addIsoTimeFields(items) + + resultData := map[string]interface{}{ + "total": data["total"], + "has_more": data["has_more"], + "page_token": data["page_token"], + "results": normalizedItems, + } + + runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) { + if len(normalizedItems) == 0 { + fmt.Fprintln(w, "No matching results found.") + return + } + + // Table output + htmlTagRe := regexp.MustCompile(``) + var rows []map[string]interface{} + for _, item := range normalizedItems { + u, _ := item.(map[string]interface{}) + if u == nil { + continue + } + + rawTitle := fmt.Sprintf("%v", u["title_highlighted"]) + title := htmlTagRe.ReplaceAllString(rawTitle, "") + title = common.TruncateStr(title, 50) + + resultMeta, _ := u["result_meta"].(map[string]interface{}) + docTypes := "" + if resultMeta != nil { + docTypes = fmt.Sprintf("%v", resultMeta["doc_types"]) + } + entityType := fmt.Sprintf("%v", u["entity_type"]) + typeStr := docTypes + if typeStr == "" || typeStr == "" { + typeStr = entityType + } + + url := "" + editTime := "" + if resultMeta != nil { + url = fmt.Sprintf("%v", resultMeta["url"]) + editTime = fmt.Sprintf("%v", resultMeta["update_time_iso"]) + } + if len(url) > 80 { + url = url[:80] + } + + rows = append(rows, map[string]interface{}{ + "type": typeStr, + "title": title, + "edit_time": editTime, + "url": url, + }) + } + + output.PrintTable(w, rows) + moreHint := "" + hasMore, _ := data["has_more"].(bool) + if hasMore { + moreHint = " (more available, use --format json to get page_token, then --page-token to paginate)" + } + fmt.Fprintf(w, "\n%d result(s)%s\n", len(rows), moreHint) + }) + return nil + }, +} + +func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (map[string]interface{}, error) { + pageSize, _ := strconv.Atoi(pageSizeStr) + if pageSize <= 0 { + pageSize = 15 + } + if pageSize > 20 { + pageSize = 20 + } + + requestData := map[string]interface{}{ + "query": query, + "page_size": pageSize, + } + if pageToken != "" { + requestData["page_token"] = pageToken + } + + if filterStr == "" { + requestData["doc_filter"] = map[string]interface{}{} + requestData["wiki_filter"] = map[string]interface{}{} + return requestData, nil + } + + var filter map[string]interface{} + if err := json.Unmarshal([]byte(filterStr), &filter); err != nil { + return nil, output.ErrValidation("--filter is not valid JSON") + } + if err := convertTimeRangeInFilter(filter, "open_time"); err != nil { + return nil, err + } + if err := convertTimeRangeInFilter(filter, "create_time"); err != nil { + return nil, err + } + + requestData["doc_filter"] = filter + wikiFilter := make(map[string]interface{}, len(filter)) + for k, v := range filter { + wikiFilter[k] = v + } + requestData["wiki_filter"] = wikiFilter + return requestData, nil +} + +// convertTimeRangeInFilter converts ISO 8601 time range to Unix seconds. +func convertTimeRangeInFilter(filter map[string]interface{}, key string) error { + val, ok := filter[key] + if !ok { + return nil + } + rangeMap, ok := val.(map[string]interface{}) + if !ok { + return nil + } + + result := make(map[string]interface{}) + if start, ok := rangeMap["start"].(string); ok && start != "" { + startTime, err := toUnixSeconds(start) + if err != nil { + return output.ErrValidation("invalid %s.start %q: %s", key, start, err) + } + result["start"] = startTime + } + if end, ok := rangeMap["end"].(string); ok && end != "" { + endTime, err := toUnixSeconds(end) + if err != nil { + return output.ErrValidation("invalid %s.end %q: %s", key, end, err) + } + result["end"] = endTime + } + filter[key] = result + return nil +} + +func toUnixSeconds(input string) (int64, error) { + formats := []string{ + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, f := range formats { + if t, err := time.ParseInLocation(f, input, time.Local); err == nil { + return t.Unix(), nil + } + } + // Try as number + if n, err := strconv.ParseInt(input, 10, 64); err == nil { + return n, nil + } + return 0, fmt.Errorf("expected RFC3339, YYYY-MM-DD[ HH:MM:SS], or unix seconds") +} + +func unixTimestampToISO8601(v interface{}) string { + if v == nil { + return "" + } + + var num float64 + switch val := v.(type) { + case float64: + num = val + case json.Number: + parsed, err := val.Float64() + if err != nil { + return "" + } + num = parsed + case string: + parsed, err := strconv.ParseFloat(val, 64) + if err != nil { + return "" + } + num = parsed + default: + return "" + } + + if math.IsInf(num, 0) || math.IsNaN(num) { + return "" + } + + // Heuristic: >= 1e12 treat as ms, else seconds + ms := int64(num) + if num >= 1e12 { + ms = ms / 1000 + } + t := time.Unix(ms, 0) + return t.Format(time.RFC3339) +} + +// addIsoTimeFields recursively adds *_time_iso fields. +func addIsoTimeFields(value interface{}) []interface{} { + if arr, ok := value.([]interface{}); ok { + result := make([]interface{}, len(arr)) + for i, item := range arr { + result[i] = addIsoTimeFieldsOne(item) + } + return result + } + return nil +} + +func addIsoTimeFieldsOne(value interface{}) interface{} { + switch v := value.(type) { + case []interface{}: + result := make([]interface{}, len(v)) + for i, item := range v { + result[i] = addIsoTimeFieldsOne(item) + } + return result + case map[string]interface{}: + out := make(map[string]interface{}) + for key, item := range v { + if strings.HasSuffix(key, "_time_iso") { + out[key] = item + continue + } + out[key] = addIsoTimeFieldsOne(item) + if strings.HasSuffix(key, "_time") { + iso := unixTimestampToISO8601(item) + if iso != "" { + out[key+"_iso"] = iso + } + } + } + return out + default: + return value + } +} diff --git a/shortcuts/doc/docs_search_test.go b/shortcuts/doc/docs_search_test.go new file mode 100644 index 00000000..36f16f81 --- /dev/null +++ b/shortcuts/doc/docs_search_test.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestAddIsoTimeFieldsSupportsJSONNumber(t *testing.T) { + t.Parallel() + + items := []interface{}{ + map[string]interface{}{ + "result_meta": map[string]interface{}{ + "update_time": json.Number("1774429274"), + }, + }, + } + + got := addIsoTimeFields(items) + item, _ := got[0].(map[string]interface{}) + meta, _ := item["result_meta"].(map[string]interface{}) + want := unixTimestampToISO8601("1774429274") + if meta["update_time_iso"] != want { + t.Fatalf("update_time_iso = %v, want %q", meta["update_time_iso"], want) + } +} + +func TestToUnixSeconds(t *testing.T) { + t.Parallel() + + got, err := toUnixSeconds("2026-03-25") + if err != nil { + t.Fatalf("toUnixSeconds() unexpected error: %v", err) + } + if got <= 0 { + t.Fatalf("toUnixSeconds() = %d, want positive unix timestamp", got) + } +} + +func TestToUnixSecondsRejectsInvalidInput(t *testing.T) { + t.Parallel() + + if _, err := toUnixSeconds("not-a-time"); err == nil { + t.Fatalf("expected invalid time error, got nil") + } +} + +func TestBuildDocsSearchRequestRejectsInvalidTime(t *testing.T) { + t.Parallel() + + _, err := buildDocsSearchRequest( + "query", + `{"open_time":{"start":"not-a-time"}}`, + "", + "15", + ) + if err == nil { + t.Fatalf("expected invalid time error, got nil") + } + if !strings.Contains(err.Error(), "invalid open_time.start") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildDocsSearchRequestUsesStartAndEndKeys(t *testing.T) { + t.Parallel() + + req, err := buildDocsSearchRequest( + "query", + `{"open_time":{"start":"2026-03-25","end":"2026-03-26"}}`, + "", + "15", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + docFilter, ok := req["doc_filter"].(map[string]interface{}) + if !ok { + t.Fatalf("doc_filter has unexpected type %T", req["doc_filter"]) + } + openTime, ok := docFilter["open_time"].(map[string]interface{}) + if !ok { + t.Fatalf("open_time has unexpected type %T", docFilter["open_time"]) + } + if _, ok := openTime["start"]; !ok { + t.Fatalf("expected start in open_time filter, got %#v", openTime) + } + if _, ok := openTime["end"]; !ok { + t.Fatalf("expected end in open_time filter, got %#v", openTime) + } + if _, ok := openTime["start_time"]; ok { + t.Fatalf("did not expect start_time in open_time filter, got %#v", openTime) + } + if _, ok := openTime["end_time"]; ok { + t.Fatalf("did not expect end_time in open_time filter, got %#v", openTime) + } +} diff --git a/shortcuts/doc/docs_update.go b/shortcuts/doc/docs_update.go new file mode 100644 index 00000000..5c64b7cc --- /dev/null +++ b/shortcuts/doc/docs_update.go @@ -0,0 +1,158 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var validModes = map[string]bool{ + "append": true, + "overwrite": true, + "replace_range": true, + "replace_all": true, + "insert_before": true, + "insert_after": true, + "delete_range": true, +} + +var needsSelection = map[string]bool{ + "replace_range": true, + "replace_all": true, + "insert_before": true, + "insert_after": true, + "delete_range": true, +} + +var DocsUpdate = common.Shortcut{ + Service: "docs", + Command: "+update", + Description: "Update a Lark document", + Risk: "write", + Scopes: []string{"docx:document:write_only", "docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL or token", Required: true}, + {Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Required: true}, + {Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with , repeat to create multiple boards)"}, + {Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')"}, + {Name: "selection-by-title", Desc: "title locator (e.g. '## Section')"}, + {Name: "new-title", Desc: "also update document title"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + mode := runtime.Str("mode") + if !validModes[mode] { + return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode) + } + + if mode != "delete_range" && runtime.Str("markdown") == "" { + return common.FlagErrorf("--%s mode requires --markdown", mode) + } + + selEllipsis := runtime.Str("selection-with-ellipsis") + selTitle := runtime.Str("selection-by-title") + if selEllipsis != "" && selTitle != "" { + return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive") + } + + if needsSelection[mode] && selEllipsis == "" && selTitle == "" { + return common.FlagErrorf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode) + } + + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + args := map[string]interface{}{ + "doc_id": runtime.Str("doc"), + "mode": runtime.Str("mode"), + } + if v := runtime.Str("markdown"); v != "" { + args["markdown"] = v + } + if v := runtime.Str("selection-with-ellipsis"); v != "" { + args["selection_with_ellipsis"] = v + } + if v := runtime.Str("selection-by-title"); v != "" { + args["selection_by_title"] = v + } + if v := runtime.Str("new-title"); v != "" { + args["new_title"] = v + } + return common.NewDryRunAPI(). + POST(common.MCPEndpoint(runtime.Config.Brand)). + Desc("MCP tool: update-doc"). + Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}). + Set("mcp_tool", "update-doc").Set("args", args) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + args := map[string]interface{}{ + "doc_id": runtime.Str("doc"), + "mode": runtime.Str("mode"), + } + if v := runtime.Str("markdown"); v != "" { + args["markdown"] = v + } + if v := runtime.Str("selection-with-ellipsis"); v != "" { + args["selection_with_ellipsis"] = v + } + if v := runtime.Str("selection-by-title"); v != "" { + args["selection_by_title"] = v + } + if v := runtime.Str("new-title"); v != "" { + args["new_title"] = v + } + + result, err := common.CallMCPTool(runtime, "update-doc", args) + if err != nil { + return err + } + + normalizeDocsUpdateResult(result, runtime.Str("markdown")) + runtime.Out(result, nil) + return nil + }, +} + +func normalizeDocsUpdateResult(result map[string]interface{}, markdown string) { + if !isWhiteboardCreateMarkdown(markdown) { + return + } + result["board_tokens"] = normalizeBoardTokens(result["board_tokens"]) +} + +func isWhiteboardCreateMarkdown(markdown string) bool { + lower := strings.ToLower(markdown) + if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") { + return true + } + return strings.Contains(lower, "\n" + if !isWhiteboardCreateMarkdown(markdown) { + t.Fatalf("expected blank whiteboard markdown to be treated as whiteboard creation") + } + }) + + t.Run("mermaid code block", func(t *testing.T) { + markdown := "```mermaid\ngraph TD\nA-->B\n```" + if !isWhiteboardCreateMarkdown(markdown) { + t.Fatalf("expected mermaid markdown to be treated as whiteboard creation") + } + }) + + t.Run("plain markdown", func(t *testing.T) { + markdown := "## plain text" + if isWhiteboardCreateMarkdown(markdown) { + t.Fatalf("did not expect plain markdown to be treated as whiteboard creation") + } + }) +} + +func TestNormalizeDocsUpdateResult(t *testing.T) { + t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) { + result := map[string]interface{}{ + "success": true, + } + + normalizeDocsUpdateResult(result, "") + + got, ok := result["board_tokens"].([]string) + if !ok { + t.Fatalf("expected board_tokens to be []string, got %T", result["board_tokens"]) + } + if len(got) != 0 { + t.Fatalf("expected empty board_tokens, got %#v", got) + } + }) + + t.Run("normalizes board_tokens to string slice", func(t *testing.T) { + result := map[string]interface{}{ + "board_tokens": []interface{}{"board_1", "board_2"}, + } + + normalizeDocsUpdateResult(result, "") + + want := []string{"board_1", "board_2"} + got, ok := result["board_tokens"].([]string) + if !ok { + t.Fatalf("expected board_tokens to be []string, got %T", result["board_tokens"]) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("board_tokens mismatch: got %#v want %#v", got, want) + } + }) + + t.Run("leaves non whiteboard response unchanged", func(t *testing.T) { + result := map[string]interface{}{ + "success": true, + } + + normalizeDocsUpdateResult(result, "## plain text") + + if _, ok := result["board_tokens"]; ok { + t.Fatalf("did not expect board_tokens for non-whiteboard markdown") + } + }) +} diff --git a/shortcuts/doc/helpers.go b/shortcuts/doc/helpers.go new file mode 100644 index 00000000..fb58251d --- /dev/null +++ b/shortcuts/doc/helpers.go @@ -0,0 +1,65 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "encoding/json" + "strings" + + "github.com/larksuite/cli/internal/output" +) + +type documentRef struct { + Kind string + Token string +} + +func parseDocumentRef(input string) (documentRef, error) { + raw := strings.TrimSpace(input) + if raw == "" { + return documentRef{}, output.ErrValidation("--doc cannot be empty") + } + + if token, ok := extractDocumentToken(raw, "/wiki/"); ok { + return documentRef{Kind: "wiki", Token: token}, nil + } + if token, ok := extractDocumentToken(raw, "/docx/"); ok { + return documentRef{Kind: "docx", Token: token}, nil + } + if token, ok := extractDocumentToken(raw, "/doc/"); ok { + return documentRef{Kind: "doc", Token: token}, nil + } + if strings.Contains(raw, "://") { + return documentRef{}, output.ErrValidation("unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw) + } + if strings.ContainsAny(raw, "/?#") { + return documentRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw) + } + + return documentRef{Kind: "docx", Token: raw}, nil +} + +func extractDocumentToken(raw, marker string) (string, bool) { + idx := strings.Index(raw, marker) + if idx < 0 { + return "", false + } + token := raw[idx+len(marker):] + if end := strings.IndexAny(token, "/?#"); end >= 0 { + token = token[:end] + } + token = strings.TrimSpace(token) + if token == "" { + return "", false + } + return token, true +} + +func buildDriveRouteExtra(docID string) (string, error) { + extra, err := json.Marshal(map[string]string{"drive_route_token": docID}) + if err != nil { + return "", output.Errorf(output.ExitInternal, "internal_error", "failed to marshal upload extra data: %v", err) + } + return string(extra), nil +} diff --git a/shortcuts/doc/helpers_test.go b/shortcuts/doc/helpers_test.go new file mode 100644 index 00000000..22331500 --- /dev/null +++ b/shortcuts/doc/helpers_test.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "strings" + "testing" +) + +func TestParseDocumentRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantKind string + wantToken string + wantErr string + }{ + { + name: "docx url", + input: "https://example.larksuite.com/docx/xxxxxx?from=wiki", + wantKind: "docx", + wantToken: "xxxxxx", + }, + { + name: "wiki url", + input: "https://example.larksuite.com/wiki/xxxxxx?from=wiki", + wantKind: "wiki", + wantToken: "xxxxxx", + }, + { + name: "doc url", + input: "https://example.larksuite.com/doc/xxxxxx", + wantKind: "doc", + wantToken: "xxxxxx", + }, + { + name: "raw token", + input: "xxxxxx", + wantKind: "docx", + wantToken: "xxxxxx", + }, + { + name: "unsupported url", + input: "https://example.com/not-a-doc", + wantErr: "unsupported --doc input", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := parseDocumentRef(tt.input) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Kind != tt.wantKind { + t.Fatalf("parseDocumentRef(%q) kind = %q, want %q", tt.input, got.Kind, tt.wantKind) + } + if got.Token != tt.wantToken { + t.Fatalf("parseDocumentRef(%q) token = %q, want %q", tt.input, got.Token, tt.wantToken) + } + }) + } +} + +func TestBuildDriveRouteExtraEscapesJSON(t *testing.T) { + t.Parallel() + + got, err := buildDriveRouteExtra(`doc-"quoted"`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := `{"drive_route_token":"doc-\"quoted\""}` + if got != want { + t.Fatalf("buildDriveRouteExtra() = %q, want %q", got, want) + } +} diff --git a/shortcuts/doc/shortcuts.go b/shortcuts/doc/shortcuts.go new file mode 100644 index 00000000..36ac1893 --- /dev/null +++ b/shortcuts/doc/shortcuts.go @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all docs shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + DocsSearch, + DocsCreate, + DocsFetch, + DocsUpdate, + DocMediaInsert, + DocMediaDownload, + } +} diff --git a/shortcuts/drive/drive_add_comment.go b/shortcuts/drive/drive_add_comment.go new file mode 100644 index 00000000..cd72a740 --- /dev/null +++ b/shortcuts/drive/drive_add_comment.go @@ -0,0 +1,593 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "unicode/utf8" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const defaultLocateDocLimit = 10 + +type commentDocRef struct { + Kind string + Token string +} + +type resolvedCommentTarget struct { + DocID string + FileToken string + FileType string + ResolvedBy string + WikiToken string +} + +type locateDocBlock struct { + BlockID string + RawMarkdown string +} + +type locateDocMatch struct { + AnchorBlockID string + ParentBlockID string + Blocks []locateDocBlock +} + +type locateDocResult struct { + MatchCount int + Matches []locateDocMatch +} + +type commentReplyElementInput struct { + Type string `json:"type"` + Text string `json:"text"` + MentionUser string `json:"mention_user"` + Link string `json:"link"` +} + +type commentMode string + +const ( + commentModeLocal commentMode = "local" + commentModeFull commentMode = "full" +) + +var DriveAddComment = common.Shortcut{ + Service: "drive", + Command: "+add-comment", + Description: "Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx)", + Risk: "write", + Scopes: []string{ + "docx:document:readonly", + "docs:document.comment:create", + "docs:document.comment:write_only", + }, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL/token, or wiki URL that resolves to doc/docx", Required: true}, + {Name: "content", Desc: "reply_elements JSON string", Required: true}, + {Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"}, + {Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"}, + {Name: "block-id", Desc: "anchor block ID (skip MCP locate-doc if already known)"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + docRef, err := parseCommentDocRef(runtime.Str("doc")) + if err != nil { + return err + } + + if _, err := parseCommentReplyElements(runtime.Str("content")); err != nil { + return err + } + + selection := runtime.Str("selection-with-ellipsis") + blockID := strings.TrimSpace(runtime.Str("block-id")) + if strings.TrimSpace(selection) != "" && blockID != "" { + return output.ErrValidation("--selection-with-ellipsis and --block-id are mutually exclusive") + } + if runtime.Bool("full-comment") && (strings.TrimSpace(selection) != "" || blockID != "") { + return output.ErrValidation("--full-comment cannot be used with --selection-with-ellipsis or --block-id") + } + + mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID) + if mode == commentModeLocal && docRef.Kind == "doc" { + return output.ErrValidation("local comments only support docx documents; use --full-comment or omit location flags for a whole-document comment") + } + + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + docRef, _ := parseCommentDocRef(runtime.Str("doc")) + replyElements, _ := parseCommentReplyElements(runtime.Str("content")) + selection := runtime.Str("selection-with-ellipsis") + blockID := strings.TrimSpace(runtime.Str("block-id")) + mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID) + + targetToken, targetFileType, resolvedBy := dryRunResolvedCommentTarget(docRef, mode) + + createPath := "/open-apis/drive/v1/files/:file_token/new_comments" + commentBody := buildCommentCreateV2Request(targetFileType, "", replyElements) + if mode == commentModeLocal { + commentBody = buildCommentCreateV2Request(targetFileType, anchorBlockIDForDryRun(blockID), replyElements) + } + + mcpEndpoint := common.MCPEndpoint(runtime.Config.Brand) + + dry := common.NewDryRunAPI() + switch { + case mode == commentModeFull && resolvedBy == "wiki": + dry.Desc("2-step orchestration: resolve wiki -> create full comment") + case mode == commentModeFull: + dry.Desc("1-step request: create full comment") + case resolvedBy == "wiki" && strings.TrimSpace(selection) != "": + dry.Desc("3-step orchestration: resolve wiki -> locate block -> create local comment") + case resolvedBy == "wiki": + dry.Desc("2-step orchestration: resolve wiki -> create local comment") + case strings.TrimSpace(selection) != "": + dry.Desc("2-step orchestration: locate block -> create local comment") + default: + dry.Desc("1-step request: create local comment with explicit block ID") + } + + if resolvedBy == "wiki" { + dry.GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to target document"). + Params(map[string]interface{}{"token": docRef.Token}) + } + + if mode == commentModeLocal && strings.TrimSpace(selection) != "" { + step := "[1]" + if resolvedBy == "wiki" { + step = "[2]" + } + mcpArgs := map[string]interface{}{ + "doc_id": dryRunLocateDocRef(docRef), + "limit": defaultLocateDocLimit, + "selection_with_ellipsis": selection, + } + dry.POST(mcpEndpoint). + Desc(step+" MCP tool: locate-doc"). + Body(map[string]interface{}{ + "method": "tools/call", + "params": map[string]interface{}{ + "name": "locate-doc", + "arguments": mcpArgs, + }, + }). + Set("mcp_tool", "locate-doc"). + Set("args", mcpArgs) + } + + step := "[1]" + createDesc := "Create full comment" + if mode == commentModeLocal { + createDesc = "Create local comment" + step = "[2]" + if resolvedBy == "wiki" && strings.TrimSpace(selection) != "" { + step = "[3]" + } else if resolvedBy == "wiki" || strings.TrimSpace(selection) != "" { + step = "[2]" + } else { + step = "[1]" + } + } else if resolvedBy == "wiki" { + step = "[2]" + } + + return dry.POST(createPath). + Desc(step+" "+createDesc). + Body(commentBody). + Set("file_token", targetToken) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + selection := runtime.Str("selection-with-ellipsis") + blockID := strings.TrimSpace(runtime.Str("block-id")) + mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID) + + target, err := resolveCommentTarget(ctx, runtime, runtime.Str("doc"), mode) + if err != nil { + return err + } + + replyElements, err := parseCommentReplyElements(runtime.Str("content")) + if err != nil { + return err + } + + var locateResult locateDocResult + selectedMatch := 0 + if mode == commentModeLocal && blockID == "" { + _, locateResult, err = locateDocumentSelection(runtime, target, selection, defaultLocateDocLimit) + if err != nil { + return err + } + + match, idx, err := selectLocateMatch(locateResult) + if err != nil { + return err + } + blockID = match.AnchorBlockID + if strings.TrimSpace(blockID) == "" { + return output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id") + } + selectedMatch = idx + fmt.Fprintf(runtime.IO().ErrOut, "Locate-doc matched %d block(s); using match #%d (%s)\n", len(locateResult.Matches), idx, blockID) + } else if mode == commentModeLocal { + fmt.Fprintf(runtime.IO().ErrOut, "Using explicit block ID: %s\n", blockID) + } + + requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken)) + requestBody := buildCommentCreateV2Request(target.FileType, "", replyElements) + if mode == commentModeLocal { + requestBody = buildCommentCreateV2Request(target.FileType, blockID, replyElements) + } + + if mode == commentModeLocal { + fmt.Fprintf(runtime.IO().ErrOut, "Creating local comment in %s\n", common.MaskToken(target.FileToken)) + } else { + fmt.Fprintf(runtime.IO().ErrOut, "Creating full comment in %s\n", common.MaskToken(target.FileToken)) + } + + data, err := runtime.CallAPI( + "POST", + requestPath, + nil, + requestBody, + ) + if err != nil { + return err + } + + out := map[string]interface{}{ + "comment_id": data["comment_id"], + "doc_id": target.DocID, + "file_token": target.FileToken, + "file_type": target.FileType, + "resolved_by": target.ResolvedBy, + "comment_mode": string(mode), + } + if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil { + out["created_at"] = createdAt + } + if target.WikiToken != "" { + out["wiki_token"] = target.WikiToken + } + if mode == commentModeLocal { + out["anchor_block_id"] = blockID + out["selection_source"] = "block_id" + if strings.TrimSpace(selection) != "" { + out["selection_source"] = "locate-doc" + out["selection_with_ellipsis"] = selection + out["match_count"] = locateResult.MatchCount + out["match_index"] = selectedMatch + } + } else if isWhole, ok := data["is_whole"]; ok { + out["is_whole"] = isWhole + } + + runtime.Out(out, nil) + return nil + }, +} + +func resolveCommentMode(explicitFullComment bool, selection, blockID string) commentMode { + if explicitFullComment { + return commentModeFull + } + if strings.TrimSpace(selection) == "" && strings.TrimSpace(blockID) == "" { + return commentModeFull + } + return commentModeLocal +} + +func parseCommentDocRef(input string) (commentDocRef, error) { + raw := strings.TrimSpace(input) + if raw == "" { + return commentDocRef{}, output.ErrValidation("--doc cannot be empty") + } + + if token, ok := extractURLToken(raw, "/wiki/"); ok { + return commentDocRef{Kind: "wiki", Token: token}, nil + } + if token, ok := extractURLToken(raw, "/docx/"); ok { + return commentDocRef{Kind: "docx", Token: token}, nil + } + if token, ok := extractURLToken(raw, "/doc/"); ok { + return commentDocRef{Kind: "doc", Token: token}, nil + } + if strings.Contains(raw, "://") { + return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx URL, a docx token, or a wiki URL that resolves to doc/docx", raw) + } + if strings.ContainsAny(raw, "/?#") { + return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw) + } + + return commentDocRef{Kind: "docx", Token: raw}, nil +} + +func dryRunResolvedCommentTarget(docRef commentDocRef, mode commentMode) (token, fileType, resolvedBy string) { + switch docRef.Kind { + case "docx": + return docRef.Token, "docx", "docx" + case "doc": + return docRef.Token, "doc", "doc" + case "wiki": + if mode == commentModeFull { + return "", "", "wiki" + } + return "", "docx", "wiki" + default: + return "", "docx", "docx" + } +} + +func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, input string, mode commentMode) (resolvedCommentTarget, error) { + docRef, err := parseCommentDocRef(input) + if err != nil { + return resolvedCommentTarget{}, err + } + + if docRef.Kind == "docx" || docRef.Kind == "doc" { + if mode == commentModeLocal && docRef.Kind != "docx" { + return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx documents") + } + return resolvedCommentTarget{ + DocID: docRef.Token, + FileToken: docRef.Token, + FileType: docRef.Kind, + ResolvedBy: docRef.Kind, + }, nil + } + + fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token)) + data, err := runtime.CallAPI( + "GET", + "/open-apis/wiki/v2/spaces/get_node", + map[string]interface{}{"token": docRef.Token}, + nil, + ) + if err != nil { + return resolvedCommentTarget{}, err + } + + node := common.GetMap(data, "node") + objType := common.GetString(node, "obj_type") + objToken := common.GetString(node, "obj_token") + if objType == "" || objToken == "" { + return resolvedCommentTarget{}, output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data") + } + if mode == commentModeLocal && objType != "docx" { + return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments currently only support docx documents", objType) + } + if mode == commentModeFull && objType != "docx" && objType != "doc" { + return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but full comments only support doc/docx documents", objType) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken)) + return resolvedCommentTarget{ + DocID: objToken, + FileToken: objToken, + FileType: objType, + ResolvedBy: "wiki", + WikiToken: docRef.Token, + }, nil +} + +func locateDocumentSelection(runtime *common.RuntimeContext, target resolvedCommentTarget, selection string, limit int) (map[string]interface{}, locateDocResult, error) { + args := map[string]interface{}{ + "doc_id": target.DocID, + "limit": limit, + "selection_with_ellipsis": selection, + } + + result, err := common.CallMCPTool(runtime, "locate-doc", args) + if err != nil { + return nil, locateDocResult{}, err + } + + return result, parseLocateDocResult(result), nil +} + +func parseLocateDocResult(result map[string]interface{}) locateDocResult { + rawMatches := common.GetSlice(result, "matches") + locate := locateDocResult{ + MatchCount: int(common.GetFloat(result, "match_count")), + } + + for _, item := range rawMatches { + matchMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + match := locateDocMatch{ + AnchorBlockID: common.GetString(matchMap, "anchor_block_id"), + ParentBlockID: common.GetString(matchMap, "parent_block_id"), + } + for _, blockItem := range common.GetSlice(matchMap, "blocks") { + blockMap, ok := blockItem.(map[string]interface{}) + if !ok { + continue + } + match.Blocks = append(match.Blocks, locateDocBlock{ + BlockID: common.GetString(blockMap, "block_id"), + RawMarkdown: common.GetString(blockMap, "raw_markdown"), + }) + } + if match.AnchorBlockID == "" && len(match.Blocks) > 0 { + match.AnchorBlockID = match.Blocks[0].BlockID + } + locate.Matches = append(locate.Matches, match) + } + + if locate.MatchCount == 0 { + locate.MatchCount = len(locate.Matches) + } + return locate +} + +func selectLocateMatch(result locateDocResult) (locateDocMatch, int, error) { + if len(result.Matches) == 0 { + return locateDocMatch{}, 0, output.ErrValidation("locate-doc did not find any matching block") + } + + if len(result.Matches) > 1 { + return locateDocMatch{}, 0, output.ErrWithHint( + output.ExitValidation, + "ambiguous_match", + fmt.Sprintf("locate-doc matched %d blocks:\n%s", len(result.Matches), formatLocateCandidates(result.Matches)), + "narrow --selection-with-ellipsis until only one block matches", + ) + } + + return result.Matches[0], 1, nil +} + +func formatLocateCandidates(matches []locateDocMatch) string { + lines := make([]string, 0, len(matches)) + for i, match := range matches { + lines = append(lines, fmt.Sprintf("%d. anchor_block_id=%s", i+1, match.AnchorBlockID)) + } + return strings.Join(lines, "\n") +} + +func summarizeLocateMatch(match locateDocMatch) string { + if len(match.Blocks) == 0 { + return "" + } + + parts := make([]string, 0, len(match.Blocks)) + for _, block := range match.Blocks { + snippet := strings.TrimSpace(block.RawMarkdown) + if snippet == "" { + continue + } + snippet = strings.ReplaceAll(snippet, "\n", " ") + parts = append(parts, snippet) + } + return common.TruncateStr(strings.Join(parts, " | "), 120) +} + +func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) { + if strings.TrimSpace(raw) == "" { + return nil, output.ErrValidation("--content cannot be empty") + } + + var inputs []commentReplyElementInput + if err := json.Unmarshal([]byte(raw), &inputs); err != nil { + return nil, output.ErrValidation("--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err) + } + if len(inputs) == 0 { + return nil, output.ErrValidation("--content must contain at least one reply element") + } + + replyElements := make([]map[string]interface{}, 0, len(inputs)) + for i, input := range inputs { + index := i + 1 + elementType := strings.TrimSpace(input.Type) + switch elementType { + case "text": + if strings.TrimSpace(input.Text) == "" { + return nil, output.ErrValidation("--content element #%d type=text requires non-empty text", index) + } + if utf8.RuneCountInString(input.Text) > 1000 { + return nil, output.ErrValidation("--content element #%d text exceeds 1000 characters", index) + } + replyElements = append(replyElements, map[string]interface{}{ + "type": "text", + "text": input.Text, + }) + case "mention_user": + mentionUser := firstNonEmptyString(input.MentionUser, input.Text) + if mentionUser == "" { + return nil, output.ErrValidation("--content element #%d type=mention_user requires text or mention_user", index) + } + replyElements = append(replyElements, map[string]interface{}{ + "type": "mention_user", + "mention_user": mentionUser, + }) + case "link": + link := firstNonEmptyString(input.Link, input.Text) + if link == "" { + return nil, output.ErrValidation("--content element #%d type=link requires text or link", index) + } + replyElements = append(replyElements, map[string]interface{}{ + "type": "link", + "link": link, + }) + default: + return nil, output.ErrValidation("--content element #%d has unsupported type %q; allowed values: text, mention_user, link", index, input.Type) + } + } + + return replyElements, nil +} + +func buildCommentCreateV2Request(fileType, blockID string, replyElements []map[string]interface{}) map[string]interface{} { + body := map[string]interface{}{ + "file_type": fileType, + "reply_elements": replyElements, + } + if strings.TrimSpace(blockID) != "" { + body["anchor"] = map[string]interface{}{ + "block_id": blockID, + } + } + return body +} + +func anchorBlockIDForDryRun(blockID string) string { + if strings.TrimSpace(blockID) != "" { + return strings.TrimSpace(blockID) + } + return "" +} + +func dryRunLocateDocRef(docRef commentDocRef) string { + if docRef.Kind == "wiki" { + return "" + } + return docRef.Token +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func firstPresentValue(m map[string]interface{}, keys ...string) interface{} { + for _, key := range keys { + if value, ok := m[key]; ok && value != nil { + return value + } + } + return nil +} + +func extractURLToken(raw, marker string) (string, bool) { + idx := strings.Index(raw, marker) + if idx < 0 { + return "", false + } + token := raw[idx+len(marker):] + if end := strings.IndexAny(token, "/?#"); end >= 0 { + token = token[:end] + } + token = strings.TrimSpace(token) + if token == "" { + return "", false + } + return token, true +} diff --git a/shortcuts/drive/drive_add_comment_test.go b/shortcuts/drive/drive_add_comment_test.go new file mode 100644 index 00000000..ae8b02fd --- /dev/null +++ b/shortcuts/drive/drive_add_comment_test.go @@ -0,0 +1,302 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "strings" + "testing" +) + +func TestParseCommentDocRef(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantKind string + wantToken string + wantErr string + }{ + { + name: "docx url", + input: "https://example.larksuite.com/docx/xxxxxx?from=wiki", + wantKind: "docx", + wantToken: "xxxxxx", + }, + { + name: "wiki url", + input: "https://example.larksuite.com/wiki/xxxxxx", + wantKind: "wiki", + wantToken: "xxxxxx", + }, + { + name: "raw token treated as docx", + input: "xxxxxx", + wantKind: "docx", + wantToken: "xxxxxx", + }, + { + name: "old doc url", + input: "https://example.larksuite.com/doc/xxxxxx", + wantKind: "doc", + wantToken: "xxxxxx", + }, + { + name: "unsupported url", + input: "https://example.com/not-a-doc", + wantErr: "unsupported --doc input", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := parseCommentDocRef(tt.input) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Kind != tt.wantKind { + t.Fatalf("kind mismatch: want %q, got %q", tt.wantKind, got.Kind) + } + if got.Token != tt.wantToken { + t.Fatalf("token mismatch: want %q, got %q", tt.wantToken, got.Token) + } + }) + } +} + +func TestResolveCommentMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + explicitFull bool + selection string + blockID string + want commentMode + }{ + { + name: "explicit full comment", + explicitFull: true, + want: commentModeFull, + }, + { + name: "auto full comment without anchor", + explicitFull: false, + want: commentModeFull, + }, + { + name: "selection means local comment", + selection: "流程", + want: commentModeLocal, + }, + { + name: "block id means local comment", + blockID: "blk_123", + want: commentModeLocal, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := resolveCommentMode(tt.explicitFull, tt.selection, tt.blockID) + if got != tt.want { + t.Fatalf("mode mismatch: want %q, got %q", tt.want, got) + } + }) + } +} + +func TestSelectLocateMatch(t *testing.T) { + t.Parallel() + + result := locateDocResult{ + MatchCount: 2, + Matches: []locateDocMatch{ + { + AnchorBlockID: "blk_1", + Blocks: []locateDocBlock{ + {BlockID: "blk_1", RawMarkdown: "流程\n"}, + }, + }, + { + AnchorBlockID: "blk_2", + Blocks: []locateDocBlock{ + {BlockID: "blk_2", RawMarkdown: "流程图\n"}, + }, + }, + }, + } + + _, _, err := selectLocateMatch(result) + if err == nil || !strings.Contains(err.Error(), "matched 2 blocks") { + t.Fatalf("expected ambiguous match error, got %v", err) + } + if strings.Contains(err.Error(), "流程") || strings.Contains(err.Error(), "流程图") { + t.Fatalf("ambiguous match error should not leak locate-doc snippets: %v", err) + } + if !strings.Contains(err.Error(), "anchor_block_id=blk_1") || !strings.Contains(err.Error(), "anchor_block_id=blk_2") { + t.Fatalf("ambiguous match error should keep anchor block identifiers: %v", err) + } +} + +func TestParseLocateDocResultFallsBackToFirstBlock(t *testing.T) { + t.Parallel() + + got := parseLocateDocResult(map[string]interface{}{ + "match_count": float64(1), + "matches": []interface{}{ + map[string]interface{}{ + "blocks": []interface{}{ + map[string]interface{}{ + "block_id": "blk_anchor", + "raw_markdown": "流程\n", + }, + }, + }, + }, + }) + + if len(got.Matches) != 1 { + t.Fatalf("expected 1 match, got %d", len(got.Matches)) + } + if got.Matches[0].AnchorBlockID != "blk_anchor" { + t.Fatalf("expected fallback anchor block, got %q", got.Matches[0].AnchorBlockID) + } +} + +func TestParseCommentReplyElements(t *testing.T) { + t.Parallel() + + got, err := parseCommentReplyElements(`[{"type":"text","text":"文本信息"},{"type":"mention_user","text":"ou_123"},{"type":"link","text":"https://example.com"}]`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 3 { + t.Fatalf("expected 3 reply elements, got %d", len(got)) + } + if got[0]["type"] != "text" || got[0]["text"] != "文本信息" { + t.Fatalf("unexpected text reply element: %#v", got[0]) + } + if got[1]["type"] != "mention_user" || got[1]["mention_user"] != "ou_123" { + t.Fatalf("unexpected mention_user reply element: %#v", got[1]) + } + if got[2]["type"] != "link" || got[2]["link"] != "https://example.com" { + t.Fatalf("unexpected link reply element: %#v", got[2]) + } +} + +func TestParseCommentReplyElementsInvalid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr string + }{ + { + name: "invalid json", + input: `[{"type":"text","text":"x"}`, + wantErr: "--content is not valid JSON", + }, + { + name: "empty array", + input: `[]`, + wantErr: "must contain at least one reply element", + }, + { + name: "unsupported type", + input: `[{"type":"image","text":"x"}]`, + wantErr: "unsupported type", + }, + { + name: "mention missing value", + input: `[{"type":"mention_user","text":""}]`, + wantErr: "requires text or mention_user", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if _, err := parseCommentReplyElements(tt.input); err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} + +func TestBuildCommentCreateV2RequestFull(t *testing.T) { + t.Parallel() + + replyElements := []map[string]interface{}{ + { + "type": "text", + "text": "全文评论", + }, + } + got := buildCommentCreateV2Request("docx", "", replyElements) + + if got["file_type"] != "docx" { + t.Fatalf("expected file_type docx, got %#v", got["file_type"]) + } + if _, ok := got["anchor"]; ok { + t.Fatalf("expected no anchor for full comment, got %#v", got["anchor"]) + } + + gotReplyElements, ok := got["reply_elements"].([]map[string]interface{}) + if !ok || len(gotReplyElements) != 1 { + t.Fatalf("expected one reply element, got %#v", got["reply_elements"]) + } + if gotReplyElements[0]["type"] != "text" { + t.Fatalf("expected text element, got %#v", gotReplyElements[0]["type"]) + } + if gotReplyElements[0]["text"] != "全文评论" { + t.Fatalf("expected text %q, got %#v", "全文评论", gotReplyElements[0]["text"]) + } +} + +func TestBuildCommentCreateV2RequestLocal(t *testing.T) { + t.Parallel() + + replyElements := []map[string]interface{}{ + { + "type": "text", + "text": "评论内容", + }, + } + got := buildCommentCreateV2Request("docx", "blk_123", replyElements) + + if got["file_type"] != "docx" { + t.Fatalf("expected file_type docx, got %#v", got["file_type"]) + } + anchor, ok := got["anchor"].(map[string]interface{}) + if !ok { + t.Fatalf("expected anchor map, got %#v", got["anchor"]) + } + if anchor["block_id"] != "blk_123" { + t.Fatalf("expected block_id blk_123, got %#v", anchor["block_id"]) + } + + gotReplyElements, ok := got["reply_elements"].([]map[string]interface{}) + if !ok || len(gotReplyElements) != 1 { + t.Fatalf("expected one reply element, got %#v", got["reply_elements"]) + } + if gotReplyElements[0]["type"] != "text" || gotReplyElements[0]["text"] != "评论内容" { + t.Fatalf("unexpected reply element: %#v", gotReplyElements[0]) + } +} diff --git a/shortcuts/drive/drive_download.go b/shortcuts/drive/drive_download.go new file mode 100644 index 00000000..578c415f --- /dev/null +++ b/shortcuts/drive/drive_download.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + // validate import used below + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var DriveDownload = common.Shortcut{ + Service: "drive", + Command: "+download", + Description: "Download a file from Drive to local", + Risk: "read", + Scopes: []string{"drive:file:download"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file-token", Desc: "file token", Required: true}, + {Name: "output", Desc: "local save path"}, + {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + fileToken := runtime.Str("file-token") + outputPath := runtime.Str("output") + if outputPath == "" { + outputPath = fileToken + } + return common.NewDryRunAPI(). + GET("/open-apis/drive/v1/files/:file_token/download"). + Set("file_token", fileToken).Set("output", outputPath) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + fileToken := runtime.Str("file-token") + outputPath := runtime.Str("output") + overwrite := runtime.Bool("overwrite") + + if err := validate.ResourceName(fileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + + if outputPath == "" { + outputPath = fileToken + } + safePath, err := validate.SafeOutputPath(outputPath) + if err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + if err := common.EnsureWritableFile(safePath, overwrite); err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s\n", common.MaskToken(fileToken)) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), + }, larkcore.WithFileDownload()) + if err != nil { + return output.ErrNetwork("download failed: %s", err) + } + + if apiResp.StatusCode >= 400 { + return output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody)) + } + + os.MkdirAll(filepath.Dir(safePath), 0755) + + if err := validate.AtomicWrite(safePath, apiResp.RawBody, 0644); err != nil { + return output.Errorf(output.ExitInternal, "api_error", "cannot create file: %s", err) + } + + runtime.Out(map[string]interface{}{ + "saved_path": safePath, + "size_bytes": len(apiResp.RawBody), + }, nil) + return nil + }, +} diff --git a/shortcuts/drive/drive_io_test.go b/shortcuts/drive/drive_io_test.go new file mode 100644 index 00000000..66750d58 --- /dev/null +++ b/shortcuts/drive/drive_io_test.go @@ -0,0 +1,159 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "bytes" + "net/http" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func driveTestConfig() *core.CliConfig { + return &core.CliConfig{ + AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + } +} + +func mountAndRunDrive(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "drive"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func withDriveWorkingDir(t *testing.T, dir string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) error: %v", dir, err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatalf("restore cwd error: %v", err) + } + }) +} + +func registerDriveBotTokenStub(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token", "expire": 7200, + }, + }) +} + +func TestDriveUploadRejectsLargeFile(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + fh, err := os.Create("large.bin") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if err := fh.Truncate(maxDriveUploadFileSize + 1); err != nil { + t.Fatalf("Truncate() error: %v", err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close() error: %v", err) + } + + err = mountAndRunDrive(t, DriveUpload, []string{ + "+upload", + "--file", "large.bin", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected size limit error, got nil") + } + if !strings.Contains(err.Error(), "exceeds 20MB limit") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDriveDownloadRejectsOverwriteWithoutFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.WriteFile("existing.bin", []byte("old"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveDownload, []string{ + "+download", + "--file-token", "file_123", + "--output", "existing.bin", + "--as", "bot", + }, f, nil) + if err == nil { + t.Fatal("expected overwrite protection error, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDriveDownloadAllowsOverwriteFlag(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + registerDriveBotTokenStub(reg) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/file_123/download", + Status: 200, + Body: []byte("new"), + Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.WriteFile("existing.bin", []byte("old"), 0644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveDownload, []string{ + "+download", + "--file-token", "file_123", + "--output", "existing.bin", + "--overwrite", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile("existing.bin") + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "new" { + t.Fatalf("downloaded file content = %q, want %q", string(data), "new") + } + if !strings.Contains(stdout.String(), "existing.bin") { + t.Fatalf("stdout missing saved path: %s", stdout.String()) + } +} diff --git a/shortcuts/drive/drive_upload.go b/shortcuts/drive/drive_upload.go new file mode 100644 index 00000000..965e8909 --- /dev/null +++ b/shortcuts/drive/drive_upload.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const maxDriveUploadFileSize = 20 * 1024 * 1024 // 20MB + +var DriveUpload = common.Shortcut{ + Service: "drive", + Command: "+upload", + Description: "Upload a local file to Drive", + Risk: "write", + Scopes: []string{"drive:file:upload"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file", Desc: "local file path (max 20MB)", Required: true}, + {Name: "folder-token", Desc: "target folder token (default: root)"}, + {Name: "name", Desc: "uploaded file name (default: local file name)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + filePath := runtime.Str("file") + folderToken := runtime.Str("folder-token") + name := runtime.Str("name") + fileName := name + if fileName == "" { + fileName = filepath.Base(filePath) + } + return common.NewDryRunAPI(). + Desc("multipart/form-data upload"). + POST("/open-apis/drive/v1/files/upload_all"). + Body(map[string]interface{}{ + "file_name": fileName, + "parent_type": "explorer", + "parent_node": folderToken, + "file": "@" + filePath, + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + filePath := runtime.Str("file") + folderToken := runtime.Str("folder-token") + name := runtime.Str("name") + + safeFilePath, err := validate.SafeInputPath(filePath) + if err != nil { + return output.ErrValidation("unsafe file path: %s", err) + } + filePath = safeFilePath + + fileName := name + if fileName == "" { + fileName = filepath.Base(filePath) + } + + info, err := os.Stat(filePath) + if err != nil { + return output.ErrValidation("cannot read file: %s", err) + } + fileSize := info.Size() + if fileSize > maxDriveUploadFileSize { + return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileSize)/1024/1024) + } + + fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s)\n", fileName, common.FormatSize(fileSize)) + + // Use SDK multipart upload + fileToken, err := uploadFileToDrive(ctx, runtime, filePath, fileName, folderToken, fileSize) + if err != nil { + return err + } + + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "file_name": fileName, + "size": fileSize, + }, nil) + return nil + }, +} + +func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, filePath, fileName, folderToken string, fileSize int64) (string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", err + } + defer f.Close() + + // Build SDK Formdata + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddField("parent_type", "explorer") + fd.AddField("parent_node", folderToken) + fd.AddField("size", fmt.Sprintf("%d", fileSize)) + fd.AddFile("file", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/files/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return "", err + } + return "", output.ErrNetwork("upload failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return "", output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) + } + + if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { + msg, _ := result["msg"].(string) + return "", output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"]) + } + + data, _ := result["data"].(map[string]interface{}) + fileToken, _ := data["file_token"].(string) + if fileToken == "" { + return "", output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + } + return fileToken, nil +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go new file mode 100644 index 00000000..fb12d6c6 --- /dev/null +++ b/shortcuts/drive/shortcuts.go @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all drive shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + DriveUpload, + DriveDownload, + DriveAddComment, + } +} diff --git a/shortcuts/event/filter.go b/shortcuts/event/filter.go new file mode 100644 index 00000000..657ae289 --- /dev/null +++ b/shortcuts/event/filter.go @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "regexp" + "sort" + "strings" +) + +// EventFilter decides whether an event should be processed. +type EventFilter interface { + Allow(eventType string) bool +} + +// FilterChain combines multiple filters with AND logic. +type FilterChain struct { + filters []EventFilter +} + +// NewFilterChain creates a filter chain. Nil filters are skipped. +func NewFilterChain(filters ...EventFilter) *FilterChain { + var valid []EventFilter + for _, f := range filters { + if f != nil { + valid = append(valid, f) + } + } + return &FilterChain{filters: valid} +} + +// Allow returns true when all filters pass. An empty chain allows all events. +func (c *FilterChain) Allow(eventType string) bool { + if c == nil { + return true + } + for _, f := range c.filters { + if !f.Allow(eventType) { + return false + } + } + return true +} + +// EventTypeFilter filters by an event type whitelist. +type EventTypeFilter struct { + allowed map[string]bool +} + +// NewEventTypeFilter creates a whitelist filter from a comma-separated string. +// Returns nil for empty input (meaning no filtering). +func NewEventTypeFilter(commaSeparated string) *EventTypeFilter { + if commaSeparated == "" { + return nil + } + allowed := make(map[string]bool) + for _, t := range strings.Split(commaSeparated, ",") { + t = strings.TrimSpace(t) + if t != "" { + allowed[t] = true + } + } + if len(allowed) == 0 { + return nil + } + return &EventTypeFilter{allowed: allowed} +} + +func (f *EventTypeFilter) Allow(eventType string) bool { + return f.allowed[eventType] +} + +// Types returns the whitelisted event types. +func (f *EventTypeFilter) Types() []string { + types := make([]string, 0, len(f.allowed)) + for t := range f.allowed { + types = append(types, t) + } + sort.Strings(types) + return types +} + +// RegexFilter filters event types by a regular expression. +type RegexFilter struct { + re *regexp.Regexp +} + +// NewRegexFilter compiles a regex and creates a filter. Returns nil, nil for empty input. +func NewRegexFilter(pattern string) (*RegexFilter, error) { + if pattern == "" { + return nil, nil + } + re, err := regexp.Compile(pattern) + if err != nil { + return nil, err + } + return &RegexFilter{re: re}, nil +} + +func (f *RegexFilter) Allow(eventType string) bool { + return f.re.MatchString(eventType) +} + +func (f *RegexFilter) String() string { + return f.re.String() +} diff --git a/shortcuts/event/helpers.go b/shortcuts/event/helpers.go new file mode 100644 index 00000000..81031b83 --- /dev/null +++ b/shortcuts/event/helpers.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +// ── Shared helpers for IM event processors ────────────────────────────────── +// These functions are used across multiple processor files to extract common +// fields from Lark event payloads (operator_id, user_ids, base compact fields). + +// openID extracts open_id from a nested {"open_id":"ou_xxx"} structure. +// Lark events represent user IDs as objects; this unwraps the outer layer. +func openID(v interface{}) string { + m, ok := v.(map[string]interface{}) + if !ok { + return "" + } + s, _ := m["open_id"].(string) + return s +} + +// extractUserIDs extracts open_ids from a users array: +// [{"user_id":{"open_id":"ou_xxx"},"name":"..."},...] +func extractUserIDs(users []interface{}) []string { + var ids []string + for _, u := range users { + um, ok := u.(map[string]interface{}) + if !ok { + continue + } + if id := openID(um["user_id"]); id != "" { + ids = append(ids, id) + } + } + return ids +} + +// compactBase builds the common compact output fields shared by all IM event processors. +// Every compact output includes: type (event_type), event_id, and timestamp (header create_time). +func compactBase(raw *RawEvent) map[string]interface{} { + out := map[string]interface{}{ + "type": raw.Header.EventType, + } + if raw.Header.EventID != "" { + out["event_id"] = raw.Header.EventID + } + if raw.Header.CreateTime != "" { + out["timestamp"] = raw.Header.CreateTime + } + return out +} diff --git a/shortcuts/event/pipeline.go b/shortcuts/event/pipeline.go new file mode 100644 index 00000000..28d552ff --- /dev/null +++ b/shortcuts/event/pipeline.go @@ -0,0 +1,198 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sync" + "sync/atomic" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + larkevent "github.com/larksuite/oapi-sdk-go/v3/event" +) + +const dedupTTL = 5 * time.Minute + +// PipelineConfig configures the event processing pipeline. +type PipelineConfig struct { + Mode TransformMode // determined by --compact flag + JsonFlag bool // --json: pretty JSON instead of NDJSON + OutputDir string // --output-dir: write events to files + Quiet bool // --quiet: suppress stderr status messages + Router *EventRouter // --route: regex-based output routing +} + +// EventPipeline chains filter → dedup → transform → emit. +type EventPipeline struct { + registry *ProcessorRegistry + filters *FilterChain + config PipelineConfig + eventCount atomic.Int64 + seen sync.Map // key → time.Time (first-seen timestamp) + out io.Writer + errOut io.Writer +} + +// NewEventPipeline builds an event processing pipeline. +func NewEventPipeline( + registry *ProcessorRegistry, + filters *FilterChain, + config PipelineConfig, + out, errOut io.Writer, +) *EventPipeline { + return &EventPipeline{ + registry: registry, + filters: filters, + config: config, + out: out, + errOut: errOut, + } +} + +// EnsureDirs creates all configured output directories once at startup. +func (p *EventPipeline) EnsureDirs() error { + if p.config.OutputDir != "" { + if err := os.MkdirAll(p.config.OutputDir, 0700); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + } + if p.config.Router != nil { + for _, route := range p.config.Router.routes { + if err := os.MkdirAll(route.dir, 0700); err != nil { + return fmt.Errorf("create route dir %s: %w", route.dir, err) + } + } + } + return nil +} + +// EventCount returns the number of processed events. +func (p *EventPipeline) EventCount() int64 { + return p.eventCount.Load() +} + +func (p *EventPipeline) infof(format string, args ...interface{}) { + if !p.config.Quiet { + fmt.Fprintf(p.errOut, format+"\n", args...) + } +} + +// isDuplicate returns true if key was seen within dedupTTL. +func (p *EventPipeline) isDuplicate(key string) bool { + now := time.Now() + if v, loaded := p.seen.LoadOrStore(key, now); loaded { + if ts, ok := v.(time.Time); ok && now.Sub(ts) < dedupTTL { + return true + } + p.seen.Store(key, now) + } + return false +} + +func (p *EventPipeline) cleanupSeen(now time.Time) { + p.seen.Range(func(k, v any) bool { + if ts, ok := v.(time.Time); ok && now.Sub(ts) >= dedupTTL { + p.seen.Delete(k) + } + return true + }) +} + +// Process is the pipeline entry point, called by the WebSocket callback. +func (p *EventPipeline) Process(ctx context.Context, raw *RawEvent) { + eventType := raw.Header.EventType + + // 1. Filter + if !p.filters.Allow(eventType) { + return + } + + // 2. Lookup processor + processor := p.registry.Lookup(eventType) + + // 3. Dedup + if key := processor.DeduplicateKey(raw); key != "" && p.isDuplicate(key) { + p.infof("%s[dedup]%s %s (key=%s)", output.Dim, output.Reset, eventType, key) + return + } + + n := p.eventCount.Add(1) + if n%100 == 0 { + p.cleanupSeen(time.Now()) + } + + // 4. Transform — processor returns the final serializable value + data := processor.Transform(ctx, raw, p.config.Mode) + + // 5. Output routing (framework-controlled) + // 5a. Route-based output — matched events go to route dirs + if p.config.Router != nil { + if dirs := p.config.Router.Match(eventType); len(dirs) > 0 { + for _, dir := range dirs { + p.writeAndLog(dir, n, eventType, data, raw.Header) + } + return + } + } + + // 5b. --output-dir + if p.config.OutputDir != "" { + p.writeAndLog(p.config.OutputDir, n, eventType, data, raw.Header) + return + } + + // 5c. Stdout + if p.config.JsonFlag { + output.PrintJson(p.out, data) + } else { + output.PrintNdjson(p.out, data) + } + p.infof("%s[%d]%s %s", output.Dim, n, output.Reset, eventType) +} + +// writeAndLog writes an event to a directory and logs the result. +func (p *EventPipeline) writeAndLog(dir string, n int64, eventType string, data interface{}, header larkevent.EventHeader) { + fp, err := writeEventFile(dir, data, header) + if err != nil { + output.PrintError(p.errOut, fmt.Sprintf("write failed (%s): %v", dir, err)) + } else { + p.infof("%s[%d]%s %s → %s", output.Dim, n, output.Reset, eventType, fp) + } +} + +var filenameSanitizer = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func writeEventFile(dir string, data interface{}, header larkevent.EventHeader) (string, error) { + eventID := header.EventID + if eventID == "" { + eventID = "unknown" + } + ts := header.CreateTime + if ts == "" { + ts = fmt.Sprintf("%d", os.Getpid()) + } + + safeName := filenameSanitizer.ReplaceAllString(header.EventType, "_") + filename := fmt.Sprintf("%s_%s_%s.json", safeName, eventID, ts) + outPath := filepath.Join(dir, filename) + + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", err + } + + if err := validate.AtomicWrite(outPath, append(jsonData, '\n'), 0600); err != nil { + return "", err + } + + return outPath, nil +} diff --git a/shortcuts/event/processor.go b/shortcuts/event/processor.go new file mode 100644 index 00000000..d2d51e59 --- /dev/null +++ b/shortcuts/event/processor.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" + "time" + + larkevent "github.com/larksuite/oapi-sdk-go/v3/event" +) + +// TransformMode defines the event transformation mode. +type TransformMode int + +const ( + // TransformRaw passes through with minimal processing. + TransformRaw TransformMode = iota + // TransformCompact extracts core fields, suitable for AI agent consumption. + TransformCompact +) + +// WindowConfig configures event windowing strategy (not implemented yet). +// Zero value means disabled. +type WindowConfig struct { + Duration time.Duration + GroupBy string +} + +// RawEvent is the strongly-typed V2 event envelope. +// Parsed directly from event.Body JSON bytes. +type RawEvent struct { + Schema string `json:"schema"` + Header larkevent.EventHeader `json:"header"` + Event json.RawMessage `json:"event"` +} + +// EventProcessor defines the processing strategy for each event type. +// +// Each processor implements its own Transform logic supporting Raw/Compact modes. +// The framework decides which mode to pass based on CLI flags; the processor +// decides the output format for that mode. +// +// Raw mode: return raw (the complete *RawEvent) to preserve the full original event. +// Compact mode: return a flat map[string]interface{} ready for JSON serialization, +// including semantic fields like "type", "id", "from", "to" plus domain-specific fields. +type EventProcessor interface { + // EventType returns the event type handled, e.g. "im.message.receive_v1". + // The fallback processor returns an empty string. + EventType() string + + // Transform converts raw event data to the target format. + // The returned value is serialized directly to JSON by the pipeline. + Transform(ctx context.Context, raw *RawEvent, mode TransformMode) interface{} + + // DeduplicateKey returns a deduplication key. Empty string means no dedup. + DeduplicateKey(raw *RawEvent) string + + // WindowStrategy returns window configuration. Zero value means disabled. + WindowStrategy() WindowConfig +} diff --git a/shortcuts/event/processor_generic.go b/shortcuts/event/processor_generic.go new file mode 100644 index 00000000..793a79e0 --- /dev/null +++ b/shortcuts/event/processor_generic.go @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" +) + +// GenericProcessor is the fallback for unregistered event types. +// Compact mode parses the event payload as a map; Raw mode passes through raw.Event. +type GenericProcessor struct{} + +func (p *GenericProcessor) EventType() string { return "" } + +func (p *GenericProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + // Compact: parse event as flat map, inject envelope metadata so AI + // can always identify the event type regardless of which processor ran. + var eventMap map[string]interface{} + if err := json.Unmarshal(raw.Event, &eventMap); err != nil { + return raw + } + eventMap["type"] = raw.Header.EventType + if raw.Header.EventID != "" { + eventMap["event_id"] = raw.Header.EventID + } + if raw.Header.CreateTime != "" { + eventMap["timestamp"] = raw.Header.CreateTime + } + return eventMap +} + +func (p *GenericProcessor) DeduplicateKey(raw *RawEvent) string { return raw.Header.EventID } +func (p *GenericProcessor) WindowStrategy() WindowConfig { return WindowConfig{} } diff --git a/shortcuts/event/processor_im_chat.go b/shortcuts/event/processor_im_chat.go new file mode 100644 index 00000000..585f3f0b --- /dev/null +++ b/shortcuts/event/processor_im_chat.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" +) + +// ── im.chat.updated_v1 ────────────────────────────────────────────────────── + +// ImChatUpdatedProcessor handles im.chat.updated_v1 events. +// +// Compact output fields: +// - type, event_id, timestamp (from compactBase) +// - chat_id: the group chat that was updated +// - operator_id: open_id of the user who made the change +// - external: whether this is an external (cross-tenant) chat +// - before_change: chat properties before the update (e.g. name, description) +// - after_change: chat properties after the update +type ImChatUpdatedProcessor struct{} + +func (p *ImChatUpdatedProcessor) EventType() string { return "im.chat.updated_v1" } + +func (p *ImChatUpdatedProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + var ev struct { + ChatID string `json:"chat_id"` + OperatorID interface{} `json:"operator_id"` + External bool `json:"external"` + AfterChange interface{} `json:"after_change"` + BeforeChange interface{} `json:"before_change"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + out := compactBase(raw) + if ev.ChatID != "" { + out["chat_id"] = ev.ChatID + } + if id := openID(ev.OperatorID); id != "" { + out["operator_id"] = id + } + out["external"] = ev.External + if ev.AfterChange != nil { + out["after_change"] = ev.AfterChange + } + if ev.BeforeChange != nil { + out["before_change"] = ev.BeforeChange + } + return out +} + +func (p *ImChatUpdatedProcessor) DeduplicateKey(raw *RawEvent) string { + return raw.Header.EventID +} +func (p *ImChatUpdatedProcessor) WindowStrategy() WindowConfig { return WindowConfig{} } + +// ── im.chat.disbanded_v1 ──────────────────────────────────────────────────── + +// ImChatDisbandedProcessor handles im.chat.disbanded_v1 events. +// +// Compact output fields: +// - type, event_id, timestamp (from compactBase) +// - chat_id: the group chat that was disbanded +// - operator_id: open_id of the user who disbanded the chat +// - external: whether this is an external (cross-tenant) chat +type ImChatDisbandedProcessor struct{} + +func (p *ImChatDisbandedProcessor) EventType() string { return "im.chat.disbanded_v1" } + +func (p *ImChatDisbandedProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + var ev struct { + ChatID string `json:"chat_id"` + OperatorID interface{} `json:"operator_id"` + External bool `json:"external"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + out := compactBase(raw) + if ev.ChatID != "" { + out["chat_id"] = ev.ChatID + } + if id := openID(ev.OperatorID); id != "" { + out["operator_id"] = id + } + out["external"] = ev.External + return out +} + +func (p *ImChatDisbandedProcessor) DeduplicateKey(raw *RawEvent) string { + return raw.Header.EventID +} +func (p *ImChatDisbandedProcessor) WindowStrategy() WindowConfig { return WindowConfig{} } diff --git a/shortcuts/event/processor_im_chat_member.go b/shortcuts/event/processor_im_chat_member.go new file mode 100644 index 00000000..e0c209a6 --- /dev/null +++ b/shortcuts/event/processor_im_chat_member.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" + "strings" +) + +// ── im.chat.member.bot.added_v1 / deleted_v1 ──────────────────────────────── + +// ImChatBotProcessor handles im.chat.member.bot.added_v1 and deleted_v1. +// A single struct serves both event types; the concrete type is set via constructor. +// +// Compact output fields: +// - type, event_id, timestamp (from compactBase) +// - action: "added" or "removed" +// - chat_id: the group chat where the bot was added/removed +// - operator_id: open_id of the user who performed the action +// - external: whether this is an external (cross-tenant) chat +type ImChatBotProcessor struct { + eventType string +} + +// NewImChatBotAddedProcessor creates a processor for im.chat.member.bot.added_v1. +func NewImChatBotAddedProcessor() *ImChatBotProcessor { + return &ImChatBotProcessor{eventType: "im.chat.member.bot.added_v1"} +} + +// NewImChatBotDeletedProcessor creates a processor for im.chat.member.bot.deleted_v1. +func NewImChatBotDeletedProcessor() *ImChatBotProcessor { + return &ImChatBotProcessor{eventType: "im.chat.member.bot.deleted_v1"} +} + +func (p *ImChatBotProcessor) EventType() string { return p.eventType } + +func (p *ImChatBotProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + var ev struct { + ChatID string `json:"chat_id"` + OperatorID interface{} `json:"operator_id"` + External bool `json:"external"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + out := compactBase(raw) + action := "added" + if strings.Contains(p.eventType, "deleted") { + action = "removed" + } + out["action"] = action + if ev.ChatID != "" { + out["chat_id"] = ev.ChatID + } + if id := openID(ev.OperatorID); id != "" { + out["operator_id"] = id + } + out["external"] = ev.External + return out +} + +func (p *ImChatBotProcessor) DeduplicateKey(raw *RawEvent) string { return raw.Header.EventID } +func (p *ImChatBotProcessor) WindowStrategy() WindowConfig { return WindowConfig{} } + +// ── im.chat.member.user.added_v1 / withdrawn_v1 / deleted_v1 ──────────────── + +// ImChatMemberUserProcessor handles im.chat.member.user.{added,withdrawn,deleted}_v1. +// A single struct serves all three event types; the concrete type is set via constructor. +// +// Compact output fields: +// - type, event_id, timestamp (from compactBase) +// - action: "added", "withdrawn" (user left), or "removed" (kicked by admin) +// - chat_id: the group chat affected +// - operator_id: open_id of the user who performed the action +// - user_ids: list of open_ids of the affected users +// - external: whether this is an external (cross-tenant) chat +type ImChatMemberUserProcessor struct { + eventType string +} + +// NewImChatMemberUserAddedProcessor creates a processor for im.chat.member.user.added_v1. +func NewImChatMemberUserAddedProcessor() *ImChatMemberUserProcessor { + return &ImChatMemberUserProcessor{eventType: "im.chat.member.user.added_v1"} +} + +// NewImChatMemberUserWithdrawnProcessor creates a processor for im.chat.member.user.withdrawn_v1. +func NewImChatMemberUserWithdrawnProcessor() *ImChatMemberUserProcessor { + return &ImChatMemberUserProcessor{eventType: "im.chat.member.user.withdrawn_v1"} +} + +// NewImChatMemberUserDeletedProcessor creates a processor for im.chat.member.user.deleted_v1. +func NewImChatMemberUserDeletedProcessor() *ImChatMemberUserProcessor { + return &ImChatMemberUserProcessor{eventType: "im.chat.member.user.deleted_v1"} +} + +func (p *ImChatMemberUserProcessor) EventType() string { return p.eventType } + +func (p *ImChatMemberUserProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + var ev struct { + ChatID string `json:"chat_id"` + OperatorID interface{} `json:"operator_id"` + External bool `json:"external"` + Users []interface{} `json:"users"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + out := compactBase(raw) + // Derive action from event type suffix + switch { + case strings.Contains(p.eventType, "added"): + out["action"] = "added" + case strings.Contains(p.eventType, "withdrawn"): + out["action"] = "withdrawn" + case strings.Contains(p.eventType, "deleted"): + out["action"] = "removed" + } + if ev.ChatID != "" { + out["chat_id"] = ev.ChatID + } + if id := openID(ev.OperatorID); id != "" { + out["operator_id"] = id + } + if userIDs := extractUserIDs(ev.Users); len(userIDs) > 0 { + out["user_ids"] = userIDs + } + out["external"] = ev.External + return out +} + +func (p *ImChatMemberUserProcessor) DeduplicateKey(raw *RawEvent) string { + return raw.Header.EventID +} +func (p *ImChatMemberUserProcessor) WindowStrategy() WindowConfig { + return WindowConfig{} +} diff --git a/shortcuts/event/processor_im_message.go b/shortcuts/event/processor_im_message.go new file mode 100644 index 00000000..68433c8b --- /dev/null +++ b/shortcuts/event/processor_im_message.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/larksuite/cli/internal/output" + convertlib "github.com/larksuite/cli/shortcuts/im/convert_lib" +) + +// ImMessageProcessor handles im.message.receive_v1 events. +// +// Compact output fields: +// - type, id, message_id, create_time, timestamp +// - chat_id, chat_type, message_type, sender_id +// - content: human-readable text converted via convertlib (supports text, post, image, file, card, etc.) +type ImMessageProcessor struct{} + +func (p *ImMessageProcessor) EventType() string { return "im.message.receive_v1" } + +func (p *ImMessageProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + + // Compact: unmarshal event portion into IM message structure + var ev struct { + Message struct { + MessageID string `json:"message_id"` + ChatID string `json:"chat_id"` + ChatType string `json:"chat_type"` + MessageType string `json:"message_type"` + Content string `json:"content"` + CreateTime string `json:"create_time"` + Mentions []interface{} `json:"mentions"` + } `json:"message"` + Sender struct { + SenderID struct { + OpenID string `json:"open_id"` + } `json:"sender_id"` + } `json:"sender"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + + // Card messages (interactive) are not yet supported for compact conversion; + // return raw event data directly. + if ev.Message.MessageType == "interactive" { + fmt.Fprintf(os.Stderr, "%s[hint]%s card message (interactive) compact conversion is not yet supported, returning raw event data\n", output.Dim, output.Reset) + return raw + } + + // Use convertlib to convert raw content JSON into human-readable text. + // Resolves @mention keys (e.g. @_user_1) to display names. + content := convertlib.ConvertBodyContent(ev.Message.MessageType, &convertlib.ConvertContext{ + RawContent: ev.Message.Content, + MentionMap: convertlib.BuildMentionKeyMap(ev.Message.Mentions), + }) + + // Build compact output with core message metadata + out := map[string]interface{}{ + "type": raw.Header.EventType, + } + if ev.Message.MessageID != "" { + out["id"] = ev.Message.MessageID + out["message_id"] = ev.Message.MessageID + } + if ev.Message.CreateTime != "" { + out["create_time"] = ev.Message.CreateTime + } + // Prefer header-level timestamp; fall back to message create_time + if raw.Header.CreateTime != "" { + out["timestamp"] = raw.Header.CreateTime + } else if ev.Message.CreateTime != "" { + out["timestamp"] = ev.Message.CreateTime + } + if ev.Message.ChatID != "" { + out["chat_id"] = ev.Message.ChatID + } + if ev.Message.ChatType != "" { + out["chat_type"] = ev.Message.ChatType + } + if ev.Message.MessageType != "" { + out["message_type"] = ev.Message.MessageType + } + if ev.Sender.SenderID.OpenID != "" { + out["sender_id"] = ev.Sender.SenderID.OpenID + } + if content != "" { + out["content"] = content + } + return out +} + +func (p *ImMessageProcessor) DeduplicateKey(raw *RawEvent) string { return raw.Header.EventID } +func (p *ImMessageProcessor) WindowStrategy() WindowConfig { return WindowConfig{} } diff --git a/shortcuts/event/processor_im_message_reaction.go b/shortcuts/event/processor_im_message_reaction.go new file mode 100644 index 00000000..3c56d83c --- /dev/null +++ b/shortcuts/event/processor_im_message_reaction.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" + "strings" +) + +// ImMessageReactionProcessor handles im.message.reaction.created_v1 and deleted_v1. +// A single struct serves both event types; the concrete type is set via constructor. +// +// Compact output fields: +// - type, event_id, timestamp (from compactBase) +// - action: "added" (created) or "removed" (deleted) +// - message_id: the message that was reacted to +// - emoji_type: the emoji used (e.g. "THUMBSUP") +// - operator_id: open_id of the user who added/removed the reaction +// - action_time: Unix timestamp of the action +type ImMessageReactionProcessor struct { + eventType string +} + +// NewImReactionCreatedProcessor creates a processor for im.message.reaction.created_v1. +func NewImReactionCreatedProcessor() *ImMessageReactionProcessor { + return &ImMessageReactionProcessor{eventType: "im.message.reaction.created_v1"} +} + +// NewImReactionDeletedProcessor creates a processor for im.message.reaction.deleted_v1. +func NewImReactionDeletedProcessor() *ImMessageReactionProcessor { + return &ImMessageReactionProcessor{eventType: "im.message.reaction.deleted_v1"} +} + +func (p *ImMessageReactionProcessor) EventType() string { return p.eventType } + +func (p *ImMessageReactionProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + var ev struct { + MessageID string `json:"message_id"` + ReactionType struct { + EmojiType string `json:"emoji_type"` + } `json:"reaction_type"` + OperatorType string `json:"operator_type"` + UserID struct { + OpenID string `json:"open_id"` + } `json:"user_id"` + ActionTime string `json:"action_time"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + out := compactBase(raw) + action := "added" + if strings.Contains(p.eventType, "deleted") { + action = "removed" + } + out["action"] = action + if ev.MessageID != "" { + out["message_id"] = ev.MessageID + } + if ev.ReactionType.EmojiType != "" { + out["emoji_type"] = ev.ReactionType.EmojiType + } + if ev.UserID.OpenID != "" { + out["operator_id"] = ev.UserID.OpenID + } + if ev.ActionTime != "" { + out["action_time"] = ev.ActionTime + } + return out +} + +func (p *ImMessageReactionProcessor) DeduplicateKey(raw *RawEvent) string { + return raw.Header.EventID +} +func (p *ImMessageReactionProcessor) WindowStrategy() WindowConfig { + return WindowConfig{} +} diff --git a/shortcuts/event/processor_im_message_read.go b/shortcuts/event/processor_im_message_read.go new file mode 100644 index 00000000..da7bbce6 --- /dev/null +++ b/shortcuts/event/processor_im_message_read.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" +) + +// ── im.message.message_read_v1 ────────────────────────────────────────────── + +// ImMessageReadProcessor handles im.message.message_read_v1 events. +// +// Compact output fields: +// - type, event_id, timestamp (from compactBase) +// - reader_id: the open_id of the user who read the message +// - read_time: Unix timestamp of the read action +// - message_ids: list of message IDs that were read +type ImMessageReadProcessor struct{} + +func (p *ImMessageReadProcessor) EventType() string { return "im.message.message_read_v1" } + +func (p *ImMessageReadProcessor) Transform(_ context.Context, raw *RawEvent, mode TransformMode) interface{} { + if mode == TransformRaw { + return raw + } + var ev struct { + Reader struct { + ReaderID struct { + OpenID string `json:"open_id"` + } `json:"reader_id"` + ReadTime string `json:"read_time"` + } `json:"reader"` + MessageIDList []string `json:"message_id_list"` + } + if err := json.Unmarshal(raw.Event, &ev); err != nil { + return raw + } + out := compactBase(raw) + if ev.Reader.ReaderID.OpenID != "" { + out["reader_id"] = ev.Reader.ReaderID.OpenID + } + if ev.Reader.ReadTime != "" { + out["read_time"] = ev.Reader.ReadTime + } + if len(ev.MessageIDList) > 0 { + out["message_ids"] = ev.MessageIDList + } + return out +} + +func (p *ImMessageReadProcessor) DeduplicateKey(raw *RawEvent) string { + return raw.Header.EventID +} +func (p *ImMessageReadProcessor) WindowStrategy() WindowConfig { return WindowConfig{} } diff --git a/shortcuts/event/processor_im_test.go b/shortcuts/event/processor_im_test.go new file mode 100644 index 00000000..63765f56 --- /dev/null +++ b/shortcuts/event/processor_im_test.go @@ -0,0 +1,501 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "testing" +) + +// --- im.message.message_read_v1 --- + +func TestImMessageReadProcessor_Compact(t *testing.T) { + p := &ImMessageReadProcessor{} + if p.EventType() != "im.message.message_read_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.message.message_read_v1", `{ + "reader": { + "reader_id": {"open_id": "ou_reader"}, + "read_time": "1700000001" + }, + "message_id_list": ["msg_001", "msg_002"] + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["type"] != "im.message.message_read_v1" { + t.Errorf("type = %v", result["type"]) + } + if result["reader_id"] != "ou_reader" { + t.Errorf("reader_id = %v", result["reader_id"]) + } + if result["read_time"] != "1700000001" { + t.Errorf("read_time = %v", result["read_time"]) + } + ids, ok := result["message_ids"].([]string) + if !ok || len(ids) != 2 { + t.Errorf("message_ids = %v", result["message_ids"]) + } +} + +func TestImMessageReadProcessor_Raw(t *testing.T) { + p := &ImMessageReadProcessor{} + raw := makeRawEvent("im.message.message_read_v1", `{}`) + result, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent) + if !ok { + t.Fatal("raw mode should return *RawEvent") + } + if result.Header.EventType != "im.message.message_read_v1" { + t.Errorf("EventType = %v", result.Header.EventType) + } +} + +func TestImMessageReadProcessor_UnmarshalError(t *testing.T) { + p := &ImMessageReadProcessor{} + raw := makeRawEvent("im.message.message_read_v1", `not json`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent) + if !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } + if result.Header.EventType != "im.message.message_read_v1" { + t.Errorf("EventType = %v", result.Header.EventType) + } +} + +func TestImMessageReadProcessor_Dedup(t *testing.T) { + p := &ImMessageReadProcessor{} + raw := makeRawEvent("im.message.message_read_v1", `{}`) + if k := p.DeduplicateKey(raw); k != "ev_test" { + t.Errorf("DeduplicateKey = %q", k) + } +} + +// --- im.message.reaction.created_v1 / deleted_v1 --- + +func TestImReactionCreatedProcessor_Compact(t *testing.T) { + p := NewImReactionCreatedProcessor() + if p.EventType() != "im.message.reaction.created_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.message.reaction.created_v1", `{ + "message_id": "msg_react", + "reaction_type": {"emoji_type": "THUMBSUP"}, + "operator_type": "user", + "user_id": {"open_id": "ou_reactor"}, + "action_time": "1700000002" + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "added" { + t.Errorf("action = %v, want added", result["action"]) + } + if result["message_id"] != "msg_react" { + t.Errorf("message_id = %v", result["message_id"]) + } + if result["emoji_type"] != "THUMBSUP" { + t.Errorf("emoji_type = %v", result["emoji_type"]) + } + if result["operator_id"] != "ou_reactor" { + t.Errorf("operator_id = %v", result["operator_id"]) + } + if result["action_time"] != "1700000002" { + t.Errorf("action_time = %v", result["action_time"]) + } +} + +func TestImReactionDeletedProcessor_Compact(t *testing.T) { + p := NewImReactionDeletedProcessor() + if p.EventType() != "im.message.reaction.deleted_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.message.reaction.deleted_v1", `{ + "message_id": "msg_unreact", + "reaction_type": {"emoji_type": "THUMBSUP"}, + "user_id": {"open_id": "ou_reactor"}, + "action_time": "1700000003" + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "removed" { + t.Errorf("action = %v, want removed", result["action"]) + } +} + +func TestImReactionProcessor_Raw(t *testing.T) { + p := NewImReactionCreatedProcessor() + raw := makeRawEvent("im.message.reaction.created_v1", `{}`) + if _, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent); !ok { + t.Fatal("raw mode should return *RawEvent") + } +} + +func TestImReactionProcessor_UnmarshalError(t *testing.T) { + p := NewImReactionCreatedProcessor() + raw := makeRawEvent("im.message.reaction.created_v1", `bad`) + if _, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent); !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } +} + +// --- im.chat.member.bot.added_v1 / deleted_v1 --- + +func TestImChatBotAddedProcessor_Compact(t *testing.T) { + p := NewImChatBotAddedProcessor() + if p.EventType() != "im.chat.member.bot.added_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.member.bot.added_v1", `{ + "chat_id": "oc_bot", + "operator_id": {"open_id": "ou_operator"}, + "external": false + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "added" { + t.Errorf("action = %v", result["action"]) + } + if result["chat_id"] != "oc_bot" { + t.Errorf("chat_id = %v", result["chat_id"]) + } + if result["operator_id"] != "ou_operator" { + t.Errorf("operator_id = %v", result["operator_id"]) + } + if result["external"] != false { + t.Errorf("external = %v", result["external"]) + } +} + +func TestImChatBotDeletedProcessor_Compact(t *testing.T) { + p := NewImChatBotDeletedProcessor() + if p.EventType() != "im.chat.member.bot.deleted_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.member.bot.deleted_v1", `{ + "chat_id": "oc_bot2", + "operator_id": {"open_id": "ou_op2"}, + "external": true + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "removed" { + t.Errorf("action = %v, want removed", result["action"]) + } + if result["external"] != true { + t.Errorf("external = %v, want true", result["external"]) + } +} + +func TestImChatBotProcessor_Raw(t *testing.T) { + p := NewImChatBotAddedProcessor() + raw := makeRawEvent("im.chat.member.bot.added_v1", `{}`) + if _, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent); !ok { + t.Fatal("raw mode should return *RawEvent") + } +} + +func TestImChatBotProcessor_UnmarshalError(t *testing.T) { + p := NewImChatBotAddedProcessor() + raw := makeRawEvent("im.chat.member.bot.added_v1", `{bad}`) + if _, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent); !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } +} + +// --- im.chat.member.user.added_v1 / withdrawn_v1 / deleted_v1 --- + +func TestImChatMemberUserAddedProcessor_Compact(t *testing.T) { + p := NewImChatMemberUserAddedProcessor() + if p.EventType() != "im.chat.member.user.added_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.member.user.added_v1", `{ + "chat_id": "oc_members", + "operator_id": {"open_id": "ou_admin"}, + "external": false, + "users": [ + {"user_id": {"open_id": "ou_user1"}, "name": "Alice"}, + {"user_id": {"open_id": "ou_user2"}, "name": "Bob"} + ] + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "added" { + t.Errorf("action = %v", result["action"]) + } + if result["chat_id"] != "oc_members" { + t.Errorf("chat_id = %v", result["chat_id"]) + } + if result["operator_id"] != "ou_admin" { + t.Errorf("operator_id = %v", result["operator_id"]) + } + userIDs, ok := result["user_ids"].([]string) + if !ok || len(userIDs) != 2 { + t.Fatalf("user_ids = %v", result["user_ids"]) + } + if userIDs[0] != "ou_user1" || userIDs[1] != "ou_user2" { + t.Errorf("user_ids = %v", userIDs) + } +} + +func TestImChatMemberUserWithdrawnProcessor_Compact(t *testing.T) { + p := NewImChatMemberUserWithdrawnProcessor() + if p.EventType() != "im.chat.member.user.withdrawn_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.member.user.withdrawn_v1", `{ + "chat_id": "oc_w", + "operator_id": {"open_id": "ou_self"}, + "external": false, + "users": [{"user_id": {"open_id": "ou_self"}, "name": "Self"}] + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "withdrawn" { + t.Errorf("action = %v, want withdrawn", result["action"]) + } +} + +func TestImChatMemberUserDeletedProcessor_Compact(t *testing.T) { + p := NewImChatMemberUserDeletedProcessor() + if p.EventType() != "im.chat.member.user.deleted_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.member.user.deleted_v1", `{ + "chat_id": "oc_del", + "operator_id": {"open_id": "ou_admin"}, + "users": [{"user_id": {"open_id": "ou_kicked"}}] + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["action"] != "removed" { + t.Errorf("action = %v, want removed", result["action"]) + } +} + +func TestImChatMemberUserProcessor_Raw(t *testing.T) { + p := NewImChatMemberUserAddedProcessor() + raw := makeRawEvent("im.chat.member.user.added_v1", `{}`) + if _, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent); !ok { + t.Fatal("raw mode should return *RawEvent") + } +} + +func TestImChatMemberUserProcessor_UnmarshalError(t *testing.T) { + p := NewImChatMemberUserAddedProcessor() + raw := makeRawEvent("im.chat.member.user.added_v1", `bad json`) + if _, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent); !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } +} + +// --- im.chat.updated_v1 --- + +func TestImChatUpdatedProcessor_Compact(t *testing.T) { + p := &ImChatUpdatedProcessor{} + if p.EventType() != "im.chat.updated_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.updated_v1", `{ + "chat_id": "oc_updated", + "operator_id": {"open_id": "ou_updater"}, + "external": false, + "after_change": {"name": "New Name"}, + "before_change": {"name": "Old Name"} + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["type"] != "im.chat.updated_v1" { + t.Errorf("type = %v", result["type"]) + } + if result["chat_id"] != "oc_updated" { + t.Errorf("chat_id = %v", result["chat_id"]) + } + if result["operator_id"] != "ou_updater" { + t.Errorf("operator_id = %v", result["operator_id"]) + } + after, ok := result["after_change"].(map[string]interface{}) + if !ok { + t.Fatal("after_change should be a map") + } + if after["name"] != "New Name" { + t.Errorf("after_change.name = %v", after["name"]) + } + before, ok := result["before_change"].(map[string]interface{}) + if !ok { + t.Fatal("before_change should be a map") + } + if before["name"] != "Old Name" { + t.Errorf("before_change.name = %v", before["name"]) + } +} + +func TestImChatUpdatedProcessor_Raw(t *testing.T) { + p := &ImChatUpdatedProcessor{} + raw := makeRawEvent("im.chat.updated_v1", `{}`) + if _, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent); !ok { + t.Fatal("raw mode should return *RawEvent") + } +} + +func TestImChatUpdatedProcessor_UnmarshalError(t *testing.T) { + p := &ImChatUpdatedProcessor{} + raw := makeRawEvent("im.chat.updated_v1", `???`) + if _, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent); !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } +} + +// --- im.chat.disbanded_v1 --- + +func TestImChatDisbandedProcessor_Compact(t *testing.T) { + p := &ImChatDisbandedProcessor{} + if p.EventType() != "im.chat.disbanded_v1" { + t.Fatalf("EventType = %q", p.EventType()) + } + raw := makeRawEvent("im.chat.disbanded_v1", `{ + "chat_id": "oc_disbanded", + "operator_id": {"open_id": "ou_disbander"}, + "external": true + }`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map") + } + if result["type"] != "im.chat.disbanded_v1" { + t.Errorf("type = %v", result["type"]) + } + if result["chat_id"] != "oc_disbanded" { + t.Errorf("chat_id = %v", result["chat_id"]) + } + if result["operator_id"] != "ou_disbander" { + t.Errorf("operator_id = %v", result["operator_id"]) + } + if result["external"] != true { + t.Errorf("external = %v, want true", result["external"]) + } +} + +func TestImChatDisbandedProcessor_Raw(t *testing.T) { + p := &ImChatDisbandedProcessor{} + raw := makeRawEvent("im.chat.disbanded_v1", `{}`) + if _, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent); !ok { + t.Fatal("raw mode should return *RawEvent") + } +} + +func TestImChatDisbandedProcessor_UnmarshalError(t *testing.T) { + p := &ImChatDisbandedProcessor{} + raw := makeRawEvent("im.chat.disbanded_v1", `nope`) + if _, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent); !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } +} + +// --- Registry: all IM processors registered --- + +func TestRegistryAllIMProcessors(t *testing.T) { + r := DefaultRegistry() + imTypes := []string{ + "im.message.receive_v1", + "im.message.message_read_v1", + "im.message.reaction.created_v1", + "im.message.reaction.deleted_v1", + "im.chat.member.bot.added_v1", + "im.chat.member.bot.deleted_v1", + "im.chat.member.user.added_v1", + "im.chat.member.user.withdrawn_v1", + "im.chat.member.user.deleted_v1", + "im.chat.updated_v1", + "im.chat.disbanded_v1", + } + for _, et := range imTypes { + p := r.Lookup(et) + if p.EventType() != et { + t.Errorf("Lookup(%q) returned processor with EventType=%q", et, p.EventType()) + } + } +} + +// --- Helper: openID --- + +func TestOpenID(t *testing.T) { + if id := openID(map[string]interface{}{"open_id": "ou_x"}); id != "ou_x" { + t.Errorf("got %q", id) + } + if id := openID("not a map"); id != "" { + t.Errorf("non-map should return empty, got %q", id) + } + if id := openID(nil); id != "" { + t.Errorf("nil should return empty, got %q", id) + } +} + +// --- Helper: extractUserIDs --- + +func TestExtractUserIDs(t *testing.T) { + users := []interface{}{ + map[string]interface{}{ + "user_id": map[string]interface{}{"open_id": "ou_a"}, + "name": "Alice", + }, + map[string]interface{}{ + "user_id": map[string]interface{}{"open_id": "ou_b"}, + }, + "not a map", + map[string]interface{}{ + "user_id": "not nested", + }, + } + ids := extractUserIDs(users) + if len(ids) != 2 || ids[0] != "ou_a" || ids[1] != "ou_b" { + t.Errorf("extractUserIDs = %v, want [ou_a, ou_b]", ids) + } +} + +func TestExtractUserIDs_Empty(t *testing.T) { + ids := extractUserIDs(nil) + if len(ids) != 0 { + t.Errorf("nil input should return empty, got %v", ids) + } +} + +// --- WindowStrategy for all new processors --- + +func TestWindowStrategy_IMProcessors(t *testing.T) { + processors := []EventProcessor{ + &ImMessageReadProcessor{}, + NewImReactionCreatedProcessor(), + NewImReactionDeletedProcessor(), + NewImChatBotAddedProcessor(), + NewImChatBotDeletedProcessor(), + NewImChatMemberUserAddedProcessor(), + NewImChatMemberUserWithdrawnProcessor(), + NewImChatMemberUserDeletedProcessor(), + &ImChatUpdatedProcessor{}, + &ImChatDisbandedProcessor{}, + } + for _, p := range processors { + if p.WindowStrategy() != (WindowConfig{}) { + t.Errorf("%s: WindowStrategy should return zero WindowConfig", p.EventType()) + } + } +} diff --git a/shortcuts/event/processor_test.go b/shortcuts/event/processor_test.go new file mode 100644 index 00000000..464cda88 --- /dev/null +++ b/shortcuts/event/processor_test.go @@ -0,0 +1,927 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + larkevent "github.com/larksuite/oapi-sdk-go/v3/event" +) + +// chdirTemp changes cwd to a fresh temp dir for the test duration. +func chdirTemp(t *testing.T) { + t.Helper() + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(orig) }) +} + +// helper to build a RawEvent from event-level JSON and header fields. +func makeRawEvent(eventType string, eventJSON string) *RawEvent { + return &RawEvent{ + Schema: "2.0", + Header: larkevent.EventHeader{ + EventType: eventType, + EventID: "ev_test", + }, + Event: json.RawMessage(eventJSON), + } +} + +// --- Registry --- + +func TestRegistryLookup(t *testing.T) { + r := DefaultRegistry() + p := r.Lookup("im.message.receive_v1") + if p.EventType() != "im.message.receive_v1" { + t.Errorf("got %q", p.EventType()) + } + p2 := r.Lookup("unknown.type") + if p2.EventType() != "" { + t.Errorf("fallback should have empty EventType, got %q", p2.EventType()) + } +} + +func TestRegistryDuplicateReturnsError(t *testing.T) { + r := NewProcessorRegistry(&GenericProcessor{}) + if err := r.Register(&ImMessageProcessor{}); err != nil { + t.Fatalf("first register should succeed: %v", err) + } + if err := r.Register(&ImMessageProcessor{}); err == nil { + t.Error("expected error on duplicate registration") + } +} + +// --- Filters --- + +func TestEventTypeFilter(t *testing.T) { + f := NewEventTypeFilter("im.message.receive_v1, drive.file.edit_v1") + if !f.Allow("im.message.receive_v1") { + t.Error("should allow") + } + if f.Allow("unknown.type") { + t.Error("should reject") + } +} + +func TestEventTypeFilter_Empty(t *testing.T) { + if f := NewEventTypeFilter(""); f != nil { + t.Error("empty should return nil") + } +} + +func TestRegexFilter(t *testing.T) { + f, err := NewRegexFilter("im\\.message\\..*") + if err != nil { + t.Fatal(err) + } + if !f.Allow("im.message.receive_v1") { + t.Error("should match") + } + if f.Allow("drive.file.edit_v1") { + t.Error("should not match") + } +} + +func TestRegexFilter_Invalid(t *testing.T) { + _, err := NewRegexFilter("[invalid") + if err == nil { + t.Error("should error") + } +} + +func TestFilterChain(t *testing.T) { + etf := NewEventTypeFilter("im.message.receive_v1, drive.file.edit_v1") + rf, _ := NewRegexFilter("im\\..*") + chain := NewFilterChain(etf, rf) + + if !chain.Allow("im.message.receive_v1") { + t.Error("both filters pass, should allow") + } + if chain.Allow("drive.file.edit_v1") { + t.Error("regex rejects drive, should block") + } + + empty := NewFilterChain() + if !empty.Allow("anything") { + t.Error("empty chain should allow all") + } + + var nilChain *FilterChain + if !nilChain.Allow("anything") { + t.Error("nil chain should allow all") + } +} + +func TestEventTypeFilter_TypesSorted(t *testing.T) { + f := NewEventTypeFilter("z.type,a.type,m.type") + got := f.Types() + want := []string{"a.type", "m.type", "z.type"} + if !reflect.DeepEqual(got, want) { + t.Errorf("Types() = %v, want %v", got, want) + } +} + +// --- Processors --- + +func TestImMessageProcessor_Raw(t *testing.T) { + p := &ImMessageProcessor{} + eventJSON := `{"message":{"id":"1"}}` + raw := makeRawEvent("im.message.receive_v1", eventJSON) + result, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent) + if !ok { + t.Fatal("raw mode should return *RawEvent") + } + if result.Header.EventType != "im.message.receive_v1" { + t.Errorf("EventType = %v", result.Header.EventType) + } + if result.Schema != "2.0" { + t.Errorf("Schema = %v", result.Schema) + } +} + +func TestGenericProcessor_Compact(t *testing.T) { + p := &GenericProcessor{} + eventJSON := `{"file_token":"xxx"}` + raw := makeRawEvent("drive.file.edit_v1", eventJSON) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(map[string]interface{}) + if !ok { + t.Fatal("compact should return map[string]interface{}") + } + if result["file_token"] != "xxx" { + t.Error("file_token should be preserved") + } + if result["type"] != "drive.file.edit_v1" { + t.Errorf("type = %v, want drive.file.edit_v1", result["type"]) + } + if result["event_id"] != "ev_test" { + t.Errorf("event_id = %v, want ev_test", result["event_id"]) + } +} + +func TestGenericProcessor_Raw(t *testing.T) { + p := &GenericProcessor{} + eventJSON := `{"schema":"2.0"}` + raw := makeRawEvent("drive.file.edit_v1", eventJSON) + result, ok := p.Transform(context.Background(), raw, TransformRaw).(*RawEvent) + if !ok { + t.Fatal("raw mode should return *RawEvent") + } + if result.Header.EventType != "drive.file.edit_v1" { + t.Errorf("EventType = %v", result.Header.EventType) + } +} + +// --- Pipeline --- + +func TestPipeline_Raw(t *testing.T) { + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformRaw}, &out, &errOut) + + eventJSON := `{"file_token":"xxx"}` + raw := makeRawEvent("drive.file.edit_v1", eventJSON) + raw.Header.EventID = "ev_raw" + raw.Header.CreateTime = "1700000000" + raw.Header.AppID = "cli_test" + p.Process(context.Background(), raw) + + // Raw output should be the complete original event (schema + header + event) + var outputMap map[string]interface{} + if err := json.Unmarshal(out.Bytes(), &outputMap); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + if outputMap["schema"] != "2.0" { + t.Errorf("schema = %v, want 2.0", outputMap["schema"]) + } + header, ok := outputMap["header"].(map[string]interface{}) + if !ok { + t.Fatal("raw output should contain header object") + } + if header["event_type"] != "drive.file.edit_v1" { + t.Errorf("header.event_type = %v", header["event_type"]) + } + if header["app_id"] != "cli_test" { + t.Errorf("header.app_id = %v, want cli_test", header["app_id"]) + } +} + +func TestPipeline_Filtered(t *testing.T) { + filters := NewFilterChain(NewEventTypeFilter("im.message.receive_v1")) + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{}, &out, &errOut) + + raw := makeRawEvent("drive.file.edit_v1", `{}`) + p.Process(context.Background(), raw) + + if p.EventCount() != 0 { + t.Errorf("filtered event should not be counted") + } + if out.Len() != 0 { + t.Error("filtered event should produce no output") + } +} + +func TestDeduplicateKey(t *testing.T) { + raw := makeRawEvent("im.message.receive_v1", `{}`) + if k := (&ImMessageProcessor{}).DeduplicateKey(raw); k != "ev_test" { + t.Errorf("ImMessageProcessor got %q, want ev_test", k) + } + if k := (&GenericProcessor{}).DeduplicateKey(raw); k != "ev_test" { + t.Errorf("GenericProcessor got %q, want ev_test", k) + } +} + +func TestPipeline_Dedup(t *testing.T) { + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformRaw}, &out, &errOut) + + raw := makeRawEvent("im.message.receive_v1", `{"message":{"id":"1"}}`) + + // First event should pass + p.Process(context.Background(), raw) + if p.EventCount() != 1 { + t.Fatalf("EventCount = %d, want 1", p.EventCount()) + } + firstLen := out.Len() + if firstLen == 0 { + t.Fatal("expected output from first event") + } + + // Same event_id again should be deduped + p.Process(context.Background(), raw) + if p.EventCount() != 1 { + t.Errorf("EventCount = %d, want 1 (deduped)", p.EventCount()) + } + if out.Len() != firstLen { + t.Error("duplicate event should produce no additional output") + } + + // Different event_id should pass + raw2 := makeRawEvent("im.message.receive_v1", `{"message":{"id":"2"}}`) + raw2.Header.EventID = "ev_other" + p.Process(context.Background(), raw2) + if p.EventCount() != 2 { + t.Errorf("EventCount = %d, want 2", p.EventCount()) + } +} + +// --- Pipeline: OutputDir --- + +func TestPipeline_OutputDir(t *testing.T) { + dir := t.TempDir() + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformCompact, OutputDir: dir}, &out, &errOut) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + + eventJSON := `{ + "message": { + "message_id": "msg_file", "chat_id": "oc_001", + "chat_type": "group", "message_type": "text", + "content": "{\"text\":\"file test\"}", "create_time": "1700000000" + }, + "sender": {"sender_id": {"open_id": "ou_001"}} + }` + raw := makeRawEvent("im.message.receive_v1", eventJSON) + raw.Header.EventID = "ev_file" + raw.Header.CreateTime = "1700000000" + p.Process(context.Background(), raw) + + // stdout should be empty (output goes to file) + if out.Len() != 0 { + t.Error("OutputDir mode should not write to stdout") + } + + // Verify file was created + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 file, got %d", len(entries)) + } + + // Verify file content is valid JSON + data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) + if err != nil { + t.Fatal(err) + } + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("file content is not valid JSON: %v", err) + } + if m["type"] != "im.message.receive_v1" { + t.Errorf("type = %v", m["type"]) + } +} + +// --- Pipeline: JsonFlag --- + +func TestPipeline_JsonFlag(t *testing.T) { + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformRaw, JsonFlag: true}, &out, &errOut) + + raw := makeRawEvent("drive.file.edit_v1", `{"key":"val"}`) + p.Process(context.Background(), raw) + + // --json output should be pretty-printed (contain newlines + indentation) + output := out.String() + if !strings.Contains(output, "\n") { + t.Error("--json output should be pretty-printed") + } + + var m map[string]interface{} + if err := json.Unmarshal([]byte(output), &m); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } +} + +// --- Pipeline: Quiet --- + +func TestPipeline_Quiet(t *testing.T) { + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformRaw, Quiet: true}, &out, &errOut) + + raw := makeRawEvent("im.message.receive_v1", `{}`) + p.Process(context.Background(), raw) + + if errOut.Len() != 0 { + t.Errorf("quiet mode should suppress stderr, got: %s", errOut.String()) + } +} + +// --- writeEventFile --- + +func TestWriteEventFile(t *testing.T) { + dir := t.TempDir() + header := larkevent.EventHeader{ + EventType: "im.message.receive_v1", + EventID: "ev_write", + CreateTime: "1700000000", + } + data := map[string]string{"hello": "world"} + + path, err := writeEventFile(dir, data, header) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(path, "ev_write") { + t.Errorf("path should contain event ID, got: %s", path) + } + + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), `"hello"`) { + t.Error("file should contain data") + } +} + +func TestWriteEventFile_EmptyFields(t *testing.T) { + dir := t.TempDir() + header := larkevent.EventHeader{EventType: "test.type"} + _, err := writeEventFile(dir, "data", header) + if err != nil { + t.Fatal(err) + } + + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Fatal("expected 1 file") + } + name := entries[0].Name() + if !strings.Contains(name, "unknown") { + t.Errorf("empty EventID should fallback to 'unknown', got: %s", name) + } +} + +// --- stderrLogger --- + +func TestStderrLogger(t *testing.T) { + var buf bytes.Buffer + l := &stderrLogger{w: &buf, quiet: false} + + l.Debug(context.Background(), "debug msg") + if buf.Len() != 0 { + t.Error("Debug should always be suppressed") + } + + l.Info(context.Background(), "info msg") + if !strings.Contains(buf.String(), "info msg") { + t.Error("Info should print when not quiet") + } + buf.Reset() + + l.Warn(context.Background(), "warn msg") + if !strings.Contains(buf.String(), "warn msg") { + t.Error("Warn should always print") + } + buf.Reset() + + l.Error(context.Background(), "error msg") + if !strings.Contains(buf.String(), "error msg") { + t.Error("Error should always print") + } +} + +func TestStderrLogger_Quiet(t *testing.T) { + var buf bytes.Buffer + l := &stderrLogger{w: &buf, quiet: true} + + l.Info(context.Background(), "info msg") + if buf.Len() != 0 { + t.Error("Info should be suppressed when quiet") + } + + l.Warn(context.Background(), "warn msg") + if !strings.Contains(buf.String(), "warn msg") { + t.Error("Warn should print even when quiet") + } +} + +// --- RegexFilter.String --- + +func TestRegexFilter_String(t *testing.T) { + f, _ := NewRegexFilter("im\\..*") + if f.String() != "im\\..*" { + t.Errorf("String() = %v", f.String()) + } +} + +// --- WindowStrategy --- + +func TestWindowStrategy(t *testing.T) { + im := &ImMessageProcessor{} + if im.WindowStrategy() != (WindowConfig{}) { + t.Error("should return zero WindowConfig") + } + gen := &GenericProcessor{} + if gen.WindowStrategy() != (WindowConfig{}) { + t.Error("should return zero WindowConfig") + } +} + +// --- Shortcuts --- + +func TestShortcuts(t *testing.T) { + s := Shortcuts() + if len(s) == 0 { + t.Fatal("should return at least one shortcut") + } + if s[0].Command != "+subscribe" { + t.Errorf("first shortcut command = %q", s[0].Command) + } +} + +// --- Compact unmarshal error fallback --- + +func TestImMessageProcessor_CompactUnmarshalError(t *testing.T) { + p := &ImMessageProcessor{} + raw := makeRawEvent("im.message.receive_v1", `not valid json`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent) + if !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } + if result.Header.EventType != "im.message.receive_v1" { + t.Errorf("EventType = %v", result.Header.EventType) + } +} + +func TestImMessageProcessor_CompactInteractiveFallsBackToRaw(t *testing.T) { + p := &ImMessageProcessor{} + raw := makeRawEvent("im.message.receive_v1", `{ + "message": { + "message_id": "om_interactive", + "message_type": "interactive", + "content": "{\"type\":\"template\"}" + } + }`) + + origStderr := os.Stderr + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe() error = %v", err) + } + os.Stderr = w + defer func() { + os.Stderr = origStderr + }() + + result, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent) + if err := w.Close(); err != nil { + t.Fatalf("stderr close error = %v", err) + } + hint, readErr := io.ReadAll(r) + if readErr != nil { + t.Fatalf("ReadAll(stderr) error = %v", readErr) + } + if !ok { + t.Fatal("interactive compact conversion should fallback to *RawEvent") + } + if result != raw { + t.Fatal("interactive compact conversion should return the original raw event") + } + if !strings.Contains(string(hint), "interactive") || !strings.Contains(string(hint), "returning raw event data") { + t.Fatalf("stderr hint = %q, want interactive fallback message", string(hint)) + } +} + +func TestGenericProcessor_CompactUnmarshalError(t *testing.T) { + p := &GenericProcessor{} + raw := makeRawEvent("some.type", `not valid json`) + result, ok := p.Transform(context.Background(), raw, TransformCompact).(*RawEvent) + if !ok { + t.Fatal("unmarshal error should fallback to *RawEvent") + } + if result.Header.EventType != "some.type" { + t.Errorf("EventType = %v", result.Header.EventType) + } +} + +// --- Router --- + +func TestParseRoutes(t *testing.T) { + routes, err := ParseRoutes([]string{ + `^im\.message=dir:./messages/`, + `^contact\.=dir:./contacts/`, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if routes == nil { + t.Fatal("expected non-nil router") + } + if len(routes.routes) != 2 { + t.Errorf("expected 2 routes, got %d", len(routes.routes)) + } +} + +func TestParseRoutes_Empty(t *testing.T) { + routes, err := ParseRoutes(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if routes != nil { + t.Error("expected nil router for empty input") + } + + routes2, err2 := ParseRoutes([]string{}) + if err2 != nil { + t.Fatalf("unexpected error: %v", err2) + } + if routes2 != nil { + t.Error("expected nil router for empty slice") + } +} + +func TestParseRoutes_MissingEquals(t *testing.T) { + _, err := ParseRoutes([]string{"no-equals-sign"}) + if err == nil { + t.Error("expected error for missing =") + } +} + +func TestParseRoutes_InvalidRegex(t *testing.T) { + _, err := ParseRoutes([]string{"[invalid=dir:./foo/"}) + if err == nil { + t.Error("expected error for invalid regex") + } +} + +func TestParseRoutes_MissingPrefix(t *testing.T) { + _, err := ParseRoutes([]string{`^im\.message=./messages/`}) + if err == nil { + t.Error("expected error for missing dir: prefix") + } + if !strings.Contains(err.Error(), "dir:") { + t.Errorf("error should mention dir: prefix, got: %v", err) + } +} + +func TestParseRoutes_EmptyPath(t *testing.T) { + _, err := ParseRoutes([]string{`^im\.message=dir:`}) + if err == nil { + t.Error("expected error for empty path") + } +} + +func TestParseRoutes_RejectsAbsolutePath(t *testing.T) { + _, err := ParseRoutes([]string{`^test=dir:/tmp/evil`}) + if err == nil { + t.Error("expected error for absolute path in route") + } +} + +func TestParseRoutes_RejectsTraversal(t *testing.T) { + _, err := ParseRoutes([]string{`^test=dir:../../etc/evil`}) + if err == nil { + t.Error("expected error for path traversal in route") + } +} + +func TestParseRoutes_PathSafety(t *testing.T) { + routes, err := ParseRoutes([]string{`^test=dir:./foo/../bar/`}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + dir := routes.routes[0].dir + if !filepath.IsAbs(dir) { + t.Errorf("expected absolute path, got %s", dir) + } + if strings.Contains(dir, "..") { + t.Errorf("expected cleaned path without .., got %s", dir) + } +} + +func TestEventRouter_Match(t *testing.T) { + chdirTemp(t) + + router, err := ParseRoutes([]string{ + `^im\.message=dir:./test_messages`, + `^contact\.=dir:./test_contacts`, + }) + if err != nil { + t.Fatal(err) + } + + // Single match + dirs := router.Match("im.message.receive_v1") + if len(dirs) != 1 { + t.Errorf("expected 1 match, got %v", dirs) + } + + dirs = router.Match("contact.user.created_v3") + if len(dirs) != 1 { + t.Errorf("expected 1 match, got %v", dirs) + } + + // No match + dirs = router.Match("drive.file.edit_v1") + if len(dirs) != 0 { + t.Errorf("expected no match, got %v", dirs) + } +} + +func TestEventRouter_Match_FanOut(t *testing.T) { + chdirTemp(t) + + router, err := ParseRoutes([]string{ + `^im\.=dir:./test_im`, + `message=dir:./test_msg`, + }) + if err != nil { + t.Fatal(err) + } + + // "im.message.receive_v1" matches both patterns + dirs := router.Match("im.message.receive_v1") + if len(dirs) != 2 { + t.Errorf("expected 2 matches (fan-out), got %d: %v", len(dirs), dirs) + } +} + +// --- Pipeline: Route --- + +func TestPipeline_Route(t *testing.T) { + chdirTemp(t) + router, err := ParseRoutes([]string{ + `^im\.message=dir:./route_out`, + }) + if err != nil { + t.Fatal(err) + } + dir := router.routes[0].dir + + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformCompact, Router: router}, &out, &errOut) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + + eventJSON := `{ + "message": { + "message_id": "msg_route", "chat_id": "oc_001", + "chat_type": "group", "message_type": "text", + "content": "{\"text\":\"routed\"}", "create_time": "1700000000" + }, + "sender": {"sender_id": {"open_id": "ou_001"}} + }` + raw := makeRawEvent("im.message.receive_v1", eventJSON) + raw.Header.EventID = "ev_route" + raw.Header.CreateTime = "1700000000" + p.Process(context.Background(), raw) + + // stdout should be empty — output goes to route dir + if out.Len() != 0 { + t.Errorf("routed event should not appear on stdout, got: %s", out.String()) + } + + // Verify file was created in route dir + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 file in route dir, got %d", len(entries)) + } + + data, err := os.ReadFile(filepath.Join(dir, entries[0].Name())) + if err != nil { + t.Fatal(err) + } + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("file content is not valid JSON: %v", err) + } + if m["type"] != "im.message.receive_v1" { + t.Errorf("type = %v", m["type"]) + } +} + +func TestPipeline_Route_NoMatch(t *testing.T) { + chdirTemp(t) + fallbackDir := t.TempDir() + + router, err := ParseRoutes([]string{ + `^im\.message=dir:./route_dir`, + }) + if err != nil { + t.Fatal(err) + } + routeDir := router.routes[0].dir + + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformCompact, Router: router, OutputDir: fallbackDir}, &out, &errOut) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + + // Send an event that does NOT match the route + raw := makeRawEvent("drive.file.edit_v1", `{"file_token":"xxx"}`) + raw.Header.EventID = "ev_nomatch" + raw.Header.CreateTime = "1700000000" + p.Process(context.Background(), raw) + + // stdout should be empty + if out.Len() != 0 { + t.Errorf("should not appear on stdout, got: %s", out.String()) + } + + // Route dir should be empty + routeEntries, _ := os.ReadDir(routeDir) + if len(routeEntries) != 0 { + t.Errorf("route dir should be empty, got %d files", len(routeEntries)) + } + + // Fallback dir should have the file + fallbackEntries, _ := os.ReadDir(fallbackDir) + if len(fallbackEntries) != 1 { + t.Fatalf("fallback dir should have 1 file, got %d", len(fallbackEntries)) + } +} + +func TestPipeline_Route_NoMatch_Stdout(t *testing.T) { + chdirTemp(t) + + router, err := ParseRoutes([]string{ + `^im\.message=dir:./route_dir`, + }) + if err != nil { + t.Fatal(err) + } + routeDir := router.routes[0].dir + + filters := NewFilterChain() + var out, errOut bytes.Buffer + // No OutputDir — unmatched events should go to stdout + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformRaw, Router: router}, &out, &errOut) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + + raw := makeRawEvent("drive.file.edit_v1", `{"file_token":"xxx"}`) + raw.Header.EventID = "ev_stdout" + raw.Header.CreateTime = "1700000000" + p.Process(context.Background(), raw) + + // Route dir should be empty + routeEntries, _ := os.ReadDir(routeDir) + if len(routeEntries) != 0 { + t.Errorf("route dir should be empty, got %d files", len(routeEntries)) + } + + // stdout should have the event + if out.Len() == 0 { + t.Error("unmatched event should fall through to stdout") + } + var m map[string]interface{} + if err := json.Unmarshal(out.Bytes(), &m); err != nil { + t.Fatalf("stdout is not valid JSON: %v", err) + } +} + +func TestPipeline_Route_FanOut(t *testing.T) { + chdirTemp(t) + + router, err := ParseRoutes([]string{ + `^im\.=dir:./fanout1`, + `message=dir:./fanout2`, + }) + if err != nil { + t.Fatal(err) + } + dir1 := router.routes[0].dir + dir2 := router.routes[1].dir + + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformCompact, Router: router}, &out, &errOut) + if err := p.EnsureDirs(); err != nil { + t.Fatal(err) + } + + eventJSON := `{ + "message": { + "message_id": "msg_fanout", "chat_id": "oc_001", + "chat_type": "group", "message_type": "text", + "content": "{\"text\":\"fanout\"}", "create_time": "1700000000" + }, + "sender": {"sender_id": {"open_id": "ou_001"}} + }` + raw := makeRawEvent("im.message.receive_v1", eventJSON) + raw.Header.EventID = "ev_fanout" + raw.Header.CreateTime = "1700000000" + p.Process(context.Background(), raw) + + // stdout should be empty + if out.Len() != 0 { + t.Errorf("fan-out event should not appear on stdout, got: %s", out.String()) + } + + // Both dirs should have a file + entries1, _ := os.ReadDir(dir1) + entries2, _ := os.ReadDir(dir2) + if len(entries1) != 1 { + t.Errorf("dir1 should have 1 file, got %d", len(entries1)) + } + if len(entries2) != 1 { + t.Errorf("dir2 should have 1 file, got %d", len(entries2)) + } +} + +// --- cleanupSeen --- + +func TestCleanupSeen(t *testing.T) { + filters := NewFilterChain() + var out, errOut bytes.Buffer + p := NewEventPipeline(DefaultRegistry(), filters, + PipelineConfig{Mode: TransformRaw}, &out, &errOut) + + // Insert an expired entry directly + p.seen.Store("old_key", time.Now().Add(-10*time.Minute)) + p.seen.Store("fresh_key", time.Now()) + + p.cleanupSeen(time.Now()) + + if _, ok := p.seen.Load("old_key"); ok { + t.Error("expired key should be cleaned up") + } + if _, ok := p.seen.Load("fresh_key"); !ok { + t.Error("fresh key should be kept") + } +} diff --git a/shortcuts/event/registry.go b/shortcuts/event/registry.go new file mode 100644 index 00000000..e51ef4f4 --- /dev/null +++ b/shortcuts/event/registry.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import "fmt" + +// ProcessorRegistry manages event_type → EventProcessor mappings. +type ProcessorRegistry struct { + processors map[string]EventProcessor + fallback EventProcessor +} + +// NewProcessorRegistry creates a registry with a fallback for unregistered event types. +func NewProcessorRegistry(fallback EventProcessor) *ProcessorRegistry { + return &ProcessorRegistry{ + processors: make(map[string]EventProcessor), + fallback: fallback, + } +} + +// Register adds a processor. Returns an error on duplicate event type registration. +func (r *ProcessorRegistry) Register(p EventProcessor) error { + et := p.EventType() + if _, exists := r.processors[et]; exists { + return fmt.Errorf("duplicate event processor for: %s", et) + } + r.processors[et] = p + return nil +} + +// Lookup finds a processor by event type. Returns fallback if not registered. Never returns nil. +func (r *ProcessorRegistry) Lookup(eventType string) EventProcessor { + if p, ok := r.processors[eventType]; ok { + return p + } + return r.fallback +} + +// DefaultRegistry builds the standard processor registry. +// To add a new processor, just add r.Register(...) here. +func DefaultRegistry() *ProcessorRegistry { + r := NewProcessorRegistry(&GenericProcessor{}) + // im.message + _ = r.Register(&ImMessageProcessor{}) + _ = r.Register(&ImMessageReadProcessor{}) + _ = r.Register(NewImReactionCreatedProcessor()) + _ = r.Register(NewImReactionDeletedProcessor()) + // im.chat.member + _ = r.Register(NewImChatBotAddedProcessor()) + _ = r.Register(NewImChatBotDeletedProcessor()) + _ = r.Register(NewImChatMemberUserAddedProcessor()) + _ = r.Register(NewImChatMemberUserWithdrawnProcessor()) + _ = r.Register(NewImChatMemberUserDeletedProcessor()) + // im.chat + _ = r.Register(&ImChatUpdatedProcessor{}) + _ = r.Register(&ImChatDisbandedProcessor{}) + return r +} diff --git a/shortcuts/event/router.go b/shortcuts/event/router.go new file mode 100644 index 00000000..07991647 --- /dev/null +++ b/shortcuts/event/router.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "fmt" + "regexp" + "strings" + + "github.com/larksuite/cli/internal/validate" +) + +// Route holds a compiled regex pattern and its target output directory. +type Route struct { + pattern *regexp.Regexp + dir string +} + +// EventRouter dispatches events to output directories by regex matching on event_type. +type EventRouter struct { + routes []Route +} + +// ParseRoutes parses route flag values into an EventRouter. +// Format: "regex=dir:./path/to/dir" +// Returns nil, nil when input is empty. +func ParseRoutes(specs []string) (*EventRouter, error) { + if len(specs) == 0 { + return nil, nil + } + + routes := make([]Route, 0, len(specs)) + for _, spec := range specs { + parts := strings.SplitN(spec, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid route %q: expected format regex=dir:./path", spec) + } + pattern := parts[0] + target := parts[1] + + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid regex in route %q: %w", spec, err) + } + + if !strings.HasPrefix(target, "dir:") { + return nil, fmt.Errorf("invalid route target %q: must start with \"dir:\" prefix (format: regex=dir:./path)", target) + } + dir := strings.TrimPrefix(target, "dir:") + if dir == "" { + return nil, fmt.Errorf("invalid route %q: directory path is empty", spec) + } + + safeDir, err := validate.SafeOutputPath(dir) + if err != nil { + return nil, fmt.Errorf("invalid route %q: %w", spec, err) + } + + routes = append(routes, Route{pattern: re, dir: safeDir}) + } + + return &EventRouter{routes: routes}, nil +} + +// Match returns all target directories for the given event type. +// Returns nil if no routes match (caller should fall through to default output). +func (r *EventRouter) Match(eventType string) []string { + var dirs []string + for _, route := range r.routes { + if route.pattern.MatchString(eventType) { + dirs = append(dirs, route.dir) + } + } + return dirs +} diff --git a/shortcuts/event/shortcuts.go b/shortcuts/event/shortcuts.go new file mode 100644 index 00000000..94f55c73 --- /dev/null +++ b/shortcuts/event/shortcuts.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all event shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + EventSubscribe, + } +} diff --git a/shortcuts/event/subscribe.go b/shortcuts/event/subscribe.go new file mode 100644 index 00000000..5b3022e6 --- /dev/null +++ b/shortcuts/event/subscribe.go @@ -0,0 +1,294 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package event + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/lockfile" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + larkevent "github.com/larksuite/oapi-sdk-go/v3/event" + "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" + larkws "github.com/larksuite/oapi-sdk-go/v3/ws" +) + +// stderrLogger redirects SDK log output to an io.Writer (stderr), +// preventing SDK logs from polluting the stdout data stream. +// Debug logs are always suppressed to avoid noisy event-loop output. +// When quiet is true, Info logs are also suppressed; Warn and Error always print. +type stderrLogger struct { + w io.Writer + quiet bool +} + +func (l *stderrLogger) Debug(_ context.Context, _ ...interface{}) {} +func (l *stderrLogger) Info(_ context.Context, args ...interface{}) { + if !l.quiet { + fmt.Fprintln(l.w, append([]interface{}{"[SDK Info]"}, args...)...) + } +} +func (l *stderrLogger) Warn(_ context.Context, args ...interface{}) { + fmt.Fprintln(l.w, append([]interface{}{"[SDK Warn]"}, args...)...) +} +func (l *stderrLogger) Error(_ context.Context, args ...interface{}) { + fmt.Fprintln(l.w, append([]interface{}{"[SDK Error]"}, args...)...) +} + +var _ larkcore.Logger = (*stderrLogger)(nil) + +// commonEventTypes are well-known event types registered in catch-all mode. +var commonEventTypes = []string{ + "im.message.receive_v1", + "im.message.message_read_v1", + "im.message.reaction.created_v1", + "im.message.reaction.deleted_v1", + "im.chat.member.bot.added_v1", + "im.chat.member.bot.deleted_v1", + "im.chat.member.user.added_v1", + "im.chat.member.user.withdrawn_v1", + "im.chat.member.user.deleted_v1", + "im.chat.updated_v1", + "im.chat.disbanded_v1", + "contact.user.created_v3", + "contact.user.updated_v3", + "contact.user.deleted_v3", + "contact.department.created_v3", + "contact.department.updated_v3", + "contact.department.deleted_v3", + "calendar.calendar.acl.created_v4", + "calendar.calendar.event.changed_v4", + "approval.approval.updated", + "application.application.visibility.added_v6", + "task.task.update_tenant_v1", + "task.task.comment_updated_v1", + "drive.notice.comment_add_v1", +} + +var EventSubscribe = common.Shortcut{ + Service: "event", + Command: "+subscribe", + Description: "Subscribe to Lark events via WebSocket (NDJSON output)", + Risk: "read", + Scopes: []string{}, // no direct OAPI; scopes depend on subscribed event types + AuthTypes: []string{"bot"}, + Flags: []common.Flag{ + // Output destination — where events go + {Name: "output-dir", Desc: "write each event as a JSON file in this directory (default: stdout)"}, + {Name: "route", Type: "string_array", Desc: "regex-based event routing (e.g. --route '^im\\.message=dir:./im/' --route '^contact\\.=dir:./contacts/'); unmatched events fall through to --output-dir or stdout"}, + // Output format — how events are serialized + {Name: "compact", Type: "bool", Desc: "flat key-value output: extract text, strip noise fields"}, + {Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"}, + // Filtering — which events reach the pipeline + {Name: "event-types", Desc: "comma-separated event types to subscribe; only use when you do not need other events (omit for catch-all)"}, + {Name: "filter", Desc: "regex to further filter events by event_type"}, + // Behavior + {Name: "quiet", Type: "bool", Desc: "suppress stderr status messages"}, + {Name: "force", Type: "bool", Desc: "bypass single-instance lock (UNSAFE: server randomly splits events across connections, each instance only receives a subset)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + eventTypesDisplay := "(catch-all)" + if s := runtime.Str("event-types"); s != "" { + eventTypesDisplay = s + } + filterDisplay := "(none)" + if s := runtime.Str("filter"); s != "" { + filterDisplay = s + } + outputDirDisplay := "(stdout)" + if s := runtime.Str("output-dir"); s != "" { + outputDirDisplay = s + } + routeDisplay := "(none)" + if routes := runtime.StrArray("route"); len(routes) > 0 { + routeDisplay = strings.Join(routes, "; ") + } + return common.NewDryRunAPI(). + Desc("Subscribe to Lark events via WebSocket (long-running)"). + Set("command", "event +subscribe"). + Set("app_id", runtime.Config.AppID). + Set("event_types", eventTypesDisplay). + Set("filter", filterDisplay).Set("output_dir", outputDirDisplay). + Set("route", routeDisplay) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + eventTypesStr := runtime.Str("event-types") + filterStr := runtime.Str("filter") + jsonFlag := runtime.Bool("json") + compactFlag := runtime.Bool("compact") + outputDir := runtime.Str("output-dir") + quietFlag := runtime.Bool("quiet") + routeSpecs := runtime.StrArray("route") + forceFlag := runtime.Bool("force") + + // Validate output directory path before any work + if outputDir != "" { + safePath, err := validate.SafeOutputPath(outputDir) + if err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + outputDir = safePath + } + + errOut := runtime.IO().ErrOut + out := runtime.IO().Out + + info := func(msg string) { + if !quietFlag { + fmt.Fprintln(errOut, msg) + } + } + + // --- Single-instance lock --- + if !forceFlag { + lock, err := lockfile.ForSubscribe(runtime.Config.AppID) + if err != nil { + return fmt.Errorf("failed to create lock: %w", err) + } + if err := lock.TryLock(); err != nil { + return output.ErrValidation( + "another event +subscribe instance is already running for app %s\n"+ + " Only one subscriber per app is allowed to prevent competing consumers.\n"+ + " Use --force to bypass this check.", + runtime.Config.AppID, + ) + } + defer lock.Unlock() + } + + // --- Build filter chain --- + eventTypeFilter := NewEventTypeFilter(eventTypesStr) + regexFilter, err := NewRegexFilter(filterStr) + if err != nil { + return output.ErrValidation("invalid --filter regex: %s", filterStr) + } + var filterList []EventFilter + if eventTypeFilter != nil { + filterList = append(filterList, eventTypeFilter) + } + if regexFilter != nil { + filterList = append(filterList, regexFilter) + } + filters := NewFilterChain(filterList...) + + // --- Parse route --- + router, err := ParseRoutes(routeSpecs) + if err != nil { + return output.ErrValidation("invalid --route: %v", err) + } + + // --- Build pipeline --- + mode := TransformRaw + if compactFlag { + mode = TransformCompact + } + pipeline := NewEventPipeline(DefaultRegistry(), filters, PipelineConfig{ + Mode: mode, + JsonFlag: jsonFlag, + OutputDir: outputDir, + Quiet: quietFlag, + Router: router, + }, out, errOut) + + if err := pipeline.EnsureDirs(); err != nil { + return err + } + + // --- Build SDK event dispatcher --- + rawHandler := func(ctx context.Context, event *larkevent.EventReq) error { + if event.Body == nil { + return nil + } + var raw RawEvent + if err := json.Unmarshal(event.Body, &raw); err != nil { + output.PrintError(errOut, fmt.Sprintf("failed to parse event: %v", err)) + return nil + } + pipeline.Process(ctx, &raw) + return nil + } + + sdkLogger := &stderrLogger{w: errOut, quiet: quietFlag} + + eventDispatcher := dispatcher.NewEventDispatcher("", "") + eventDispatcher.InitConfig(larkevent.WithLogger(sdkLogger)) + if eventTypeFilter != nil { + for _, et := range eventTypeFilter.Types() { + eventDispatcher.OnCustomizedEvent(et, rawHandler) + } + } else { + for _, et := range commonEventTypes { + eventDispatcher.OnCustomizedEvent(et, rawHandler) + } + } + + // --- WebSocket --- + domain := lark.FeishuBaseUrl + if runtime.Config.Brand == core.BrandLark { + domain = lark.LarkBaseUrl + } + + info(fmt.Sprintf("%sConnecting to Lark event WebSocket...%s", output.Cyan, output.Reset)) + if eventTypeFilter != nil { + info(fmt.Sprintf("Listening for: %s%s%s", output.Green, strings.Join(eventTypeFilter.Types(), ", "), output.Reset)) + } else { + info(fmt.Sprintf("Listening for %s%d common event types%s (catch-all mode)", output.Green, len(commonEventTypes), output.Reset)) + info(fmt.Sprintf("%sTip:%s use --event-types to listen for specific event types", output.Dim, output.Reset)) + } + if regexFilter != nil { + info(fmt.Sprintf("Filter: %s%s%s", output.Yellow, regexFilter.String(), output.Reset)) + } + if router != nil { + for _, spec := range routeSpecs { + info(fmt.Sprintf(" Route: %s%s%s", output.Green, spec, output.Reset)) + } + } + + cli := larkws.NewClient(runtime.Config.AppID, runtime.Config.AppSecret, + larkws.WithEventHandler(eventDispatcher), + larkws.WithDomain(domain), + larkws.WithLogger(sdkLogger), + ) + + // --- Graceful shutdown --- + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + + startErrCh := make(chan error, 1) + go func() { + startErrCh <- cli.Start(ctx) + }() + + info(fmt.Sprintf("%s%sConnected.%s Waiting for events... (Ctrl+C to stop)", output.Bold, output.Green, output.Reset)) + + select { + case sig, ok := <-sigCh: + if ok && sig != nil { + info(fmt.Sprintf("\n%sReceived %s, shutting down...%s (received %s%d%s events)", output.Yellow, sig, output.Reset, output.Bold, pipeline.EventCount(), output.Reset)) + } + return nil + case err, ok := <-startErrCh: + if !ok { + return nil + } + if err != nil { + return output.ErrNetwork("WebSocket connection failed: %v", err) + } + return nil + } + }, +} diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go new file mode 100644 index 00000000..36e06521 --- /dev/null +++ b/shortcuts/im/builders_test.go @@ -0,0 +1,633 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func mustMarshalDryRun(t *testing.T, v interface{}) string { + t.Helper() + + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + return string(b) +} + +func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext { + t.Helper() + + cmd := &cobra.Command{Use: "test"} + for name := range stringFlags { + cmd.Flags().String(name, "", "") + } + for name := range boolFlags { + cmd.Flags().Bool(name, false, "") + } + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags() error = %v", err) + } + for name, val := range stringFlags { + if err := cmd.Flags().Set(name, val); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + for name, val := range boolFlags { + if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil { + t.Fatalf("Flags().Set(%q) error = %v", name, err) + } + } + return &common.RuntimeContext{Cmd: cmd} +} + +func TestBuildCreateChatBody(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "type": "public", + "name": "Team Chat", + "description": "daily sync", + "users": "ou_1, ou_2", + "bots": "cli_1, cli_2", + "owner": "ou_owner", + }, nil) + + got := buildCreateChatBody(runtime) + want := map[string]interface{}{ + "chat_type": "public", + "name": "Team Chat", + "description": "daily sync", + "user_id_list": []string{ + "ou_1", + "ou_2", + }, + "bot_id_list": []string{ + "cli_1", + "cli_2", + }, + "owner_id": "ou_owner", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildCreateChatBody() = %#v, want %#v", got, want) + } +} + +// TestSplitMembers verifies the delegation wrapper; core logic is tested in TestSplitCSV. [#17] +func TestSplitMembers(t *testing.T) { + got := common.SplitCSV(" ou_1, ,ou_2 ,, ou_3 ") + want := []string{"ou_1", "ou_2", "ou_3"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("splitMembers() = %#v, want %#v", got, want) + } +} + +func TestBuildSearchChatBody(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "team-alpha", + "page-size": "50", + "page-token": "next_page", + }, nil) + + got := buildSearchChatBody(runtime) + want := map[string]interface{}{ + "query": `"team-alpha"`, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildSearchChatBody() = %#v, want %#v", got, want) + } +} + +func TestSplitAndTrimChat(t *testing.T) { + got := common.SplitCSV(" private, , public_joined ,, external ") + want := []string{"private", "public_joined", "external"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("common.SplitCSV() = %#v, want %#v", got, want) + } +} + +func TestBuildUpdateChatBody(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "name": "New Name", + "description": "New Description", + }, nil) + + got := buildUpdateChatBody(runtime) + want := map[string]interface{}{ + "name": "New Name", + "description": "New Description", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildUpdateChatBody() = %#v, want %#v", got, want) + } +} + +func TestIsMediaKey(t *testing.T) { + tests := []struct { + value string + want bool + }{ + {value: "img_123", want: true}, + {value: "file_123", want: true}, + {value: "/tmp/image.png", want: false}, + {value: "video.mp4", want: false}, + } + + for _, tt := range tests { + if got := isMediaKey(tt.value); got != tt.want { + t.Fatalf("isMediaKey(%q) = %v, want %v", tt.value, got, tt.want) + } + } +} + +func TestShortcutValidateBranches(t *testing.T) { + + t.Run("ImChatCreate valid", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "type": "public", + "name": "Team Room", + "users": "ou_1,ou_2", + "bots": "cli_1", + "owner": "ou_owner", + }, nil) + if err := ImChatCreate.Validate(context.Background(), runtime); err != nil { + t.Fatalf("ImChatCreate.Validate() unexpected error = %v", err) + } + }) + + t.Run("ImChatCreate name too long", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "name": strings.Repeat("长", 61), + }, nil) + err := ImChatCreate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--name exceeds the maximum of 60 characters") { + t.Fatalf("ImChatCreate.Validate() error = %v", err) + } + }) + + t.Run("ImChatCreate description too long", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "description": strings.Repeat("d", 101), + }, nil) + err := ImChatCreate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--description exceeds the maximum of 100 characters") { + t.Fatalf("ImChatCreate.Validate() error = %v", err) + } + }) + + t.Run("ImChatCreate invalid user id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "users": "ou_1,user_2", + }, nil) + err := ImChatCreate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid user ID format") { + t.Fatalf("ImChatCreate.Validate() error = %v", err) + } + }) + + t.Run("ImChatCreate too many bots", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "bots": "cli_1,cli_2,cli_3,cli_4,cli_5,cli_6", + }, nil) + err := ImChatCreate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--bots exceeds the maximum of 5") { + t.Fatalf("ImChatCreate.Validate() error = %v", err) + } + }) + + t.Run("ImChatCreate invalid owner id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "owner": "user_1", + }, nil) + err := ImChatCreate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid user ID format") { + t.Fatalf("ImChatCreate.Validate() error = %v", err) + } + }) + + t.Run("ImChatSearch invalid page size", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "ok", + "page-size": "0", + }, nil) + err := ImChatSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 100") { + t.Fatalf("ImChatSearch.Validate() error = %v", err) + } + }) + + t.Run("ImChatSearch query too long", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": strings.Repeat("q", 65), + }, nil) + err := ImChatSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--query exceeds the maximum of 64 characters") { + t.Fatalf("ImChatSearch.Validate() error = %v", err) + } + }) + + t.Run("ImChatUpdate requires fields", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + }, nil) + err := ImChatUpdate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "at least one field must be specified") { + t.Fatalf("ImChatUpdate.Validate() error = %v", err) + } + }) + + t.Run("ImChatUpdate invalid chat id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "bad_chat", + "name": "new", + }, nil) + err := ImChatUpdate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid chat ID format") { + t.Fatalf("ImChatUpdate.Validate() error = %v", err) + } + }) + + t.Run("ImChatUpdate description too long", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "description": strings.Repeat("x", 101), + }, nil) + err := ImChatUpdate.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--description exceeds the maximum of 100 characters") { + t.Fatalf("ImChatUpdate.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSend conflicting target", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "user-id": "ou_123", + "text": "hello", + }, nil) + err := ImMessagesSend.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--chat-id and --user-id are mutually exclusive") { + t.Fatalf("ImMessagesSend.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSend invalid content json", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "content": "{invalid", + }, nil) + err := ImMessagesSend.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--content is not valid JSON") { + t.Fatalf("ImMessagesSend.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSend media with text", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "text": "hello", + "image": "img_123", + }, nil) + err := ImMessagesSend.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--image/--file/--video/--audio cannot be used with --text, --markdown, or --content") { + t.Fatalf("ImMessagesSend.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSend valid text", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "text": "hello", + }, nil) + if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil { + t.Fatalf("ImMessagesSend.Validate() unexpected error = %v", err) + } + }) + + t.Run("ImMessagesSend video with video-cover passes validate", func(t *testing.T) { + // Previously broken: the deleted check used imageKey instead of videoCoverKey, + // so --video + --video-cover would incorrectly fail at Validate. + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "video": "file_456", + "video-cover": "img_789", + }, nil) + if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil { + t.Fatalf("ImMessagesSend.Validate() unexpected error = %v", err) + } + }) + + t.Run("ImMessagesSend video without video-cover fails validate", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "video": "file_456", + }, nil) + err := ImMessagesSend.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--video-cover is required when using --video") { + t.Fatalf("ImMessagesSend.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSend video-cover without video fails validate", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "video-cover": "img_789", + }, nil) + err := ImMessagesSend.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--video-cover can only be used with --video") { + t.Fatalf("ImMessagesSend.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSend conflicting explicit msg-type", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "msg-type": "file", + "image": "img_123", + }, nil) + err := ImMessagesSend.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "conflicts with the inferred message type") { + t.Fatalf("ImMessagesSend.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesReply invalid message id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "bad_id", + "text": "hello", + }, nil) + err := ImMessagesReply.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "must start with om_") { + t.Fatalf("ImMessagesReply.Validate() error = %v", err) + } + }) + + t.Run("ImThreadsMessagesList invalid thread", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "thread": "bad_thread", + }, nil) + err := ImThreadsMessagesList.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "must start with om_ or omt_") { + t.Fatalf("ImThreadsMessagesList.Validate() error = %v", err) + } + }) + + t.Run("ImChatMessageList requires one target", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{}, nil) + err := ImChatMessageList.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "specify at least one of --chat-id or --user-id") { + t.Fatalf("ImChatMessageList.Validate() error = %v", err) + } + }) + + t.Run("ImChatMessageList valid user target", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-id": "ou_123", + }, nil) + if err := ImChatMessageList.Validate(context.Background(), runtime); err != nil { + t.Fatalf("ImChatMessageList.Validate() unexpected error = %v", err) + } + }) + + t.Run("ImMessagesMGet empty ids", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-ids": " , ", + }, nil) + err := ImMessagesMGet.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--message-ids is required") { + t.Fatalf("ImMessagesMGet.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesMGet invalid id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-ids": "om_1,bad_2", + }, nil) + err := ImMessagesMGet.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid message ID") { + t.Fatalf("ImMessagesMGet.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesResourcesDownload invalid message id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "bad_id", + "file-key": "img_123", + "type": "image", + }, nil) + err := ImMessagesResourcesDownload.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "must start with om_") { + t.Fatalf("ImMessagesResourcesDownload.Validate() error = %v", err) + } + }) + + t.Run("ImThreadsMessagesList valid omt thread", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "thread": "omt_123", + }, nil) + if err := ImThreadsMessagesList.Validate(context.Background(), runtime); err != nil { + t.Fatalf("ImThreadsMessagesList.Validate() unexpected error = %v", err) + } + }) + + t.Run("ImMessagesSearch invalid page size", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "incident", + "page-size": "0", + }, nil) + err := ImMessagesSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 50") { + t.Fatalf("ImMessagesSearch.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSearch invalid sender id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "sender": "user_1", + }, nil) + err := ImMessagesSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid user ID") { + t.Fatalf("ImMessagesSearch.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSearch invalid chat id", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "bad_chat", + }, nil) + err := ImMessagesSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid chat ID") { + t.Fatalf("ImMessagesSearch.Validate() error = %v", err) + } + }) + + t.Run("ImMessagesSearch invalid time range", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "start": "2025-01-02T00:00:00Z", + "end": "2025-01-01T00:00:00Z", + }, nil) + err := ImMessagesSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--start cannot be later than --end") { + t.Fatalf("ImMessagesSearch.Validate() error = %v", err) + } + }) +} + +func TestShortcutDryRunShapes(t *testing.T) { + t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "type": "public", + "name": "Team Room", + "users": "ou_1,ou_2", + "owner": "ou_owner", + }, map[string]bool{ + "set-bot-manager": true, + }) + got := mustMarshalDryRun(t, ImChatCreate.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) { + t.Fatalf("ImChatCreate.DryRun() = %s", got) + } + }) + + t.Run("ImChatSearch dry run includes built params", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "team-alpha", + "page-size": "50", + "page-token": "next_page", + }, nil) + got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":20`) || !strings.Contains(got, `"query":"\"team-alpha\""`) { + t.Fatalf("ImChatSearch.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "incident", + "page-size": "51", + "page-token": "next_page", + }, nil) + got := mustMarshalDryRun(t, ImMessagesSearch.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/messages/search"`) || !strings.Contains(got, `"page_size":"50"`) || !strings.Contains(got, `"query":"incident"`) { + t.Fatalf("ImMessagesSearch.DryRun() = %s", got) + } + }) + + t.Run("ImChatUpdate dry run resolves path", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "name": "New Name", + "description": "New Description", + }, nil) + got := mustMarshalDryRun(t, ImChatUpdate.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/chats/oc_123"`) || !strings.Contains(got, `"user_id_type":"open_id"`) || !strings.Contains(got, `"name":"New Name"`) { + t.Fatalf("ImChatUpdate.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesSend dry run resolves open_id target", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-id": "ou_123", + "image": "img_123", + "idempotency-key": "uuid-2", + }, nil) + got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"receive_id_type":"open_id"`) || !strings.Contains(got, `"msg_type":"image"`) || !strings.Contains(got, `"uuid":"uuid-2"`) || !strings.Contains(got, `\"image_key\":\"img_123\"`) { + t.Fatalf("ImMessagesSend.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesSend dry run uses placeholder media key for url input", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "chat-id": "oc_123", + "image": "https://example.com/a.png", + }, nil) + got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"description":"dry-run uses placeholder media keys for --image URL input; execution uploads it before sending"`) || + !strings.Contains(got, `"msg_type":"image"`) || + !strings.Contains(got, `\"image_key\":\"img_dryrun_upload\"`) { + t.Fatalf("ImMessagesSend.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesMGet dry run expands message ids", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-ids": "om_1,om_2", + }, nil) + got := mustMarshalDryRun(t, ImMessagesMGet.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/messages/mget?card_msg_content_type=raw_card_content\u0026message_ids=om_1\u0026message_ids=om_2"`) { + t.Fatalf("ImMessagesMGet.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesResourcesDownload dry run resolves path", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "om_123", + "file-key": "img_123", + "type": "image", + "output": "downloads/out.png", + }, nil) + got := mustMarshalDryRun(t, ImMessagesResourcesDownload.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"/open-apis/im/v1/messages/om_123/resources/img_123"`) || !strings.Contains(got, `"type":"image"`) || !strings.Contains(got, `"output":"downloads/out.png"`) { + t.Fatalf("ImMessagesResourcesDownload.DryRun() = %s", got) + } + }) + + t.Run("ImThreadsMessagesList dry run keeps requested thread params", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "thread": "omt_123", + "sort": "desc", + "page-size": "10", + }, nil) + got := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":10`) { + t.Fatalf("ImThreadsMessagesList.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesReply dry run resolves message path and body", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "om_123", + "text": "hi ", + "idempotency-key": "uuid-1", + }, map[string]bool{ + "reply-in-thread": true, + }) + got := mustMarshalDryRun(t, ImMessagesReply.DryRun(context.Background(), runtime)) + if !strings.Contains(got, "/open-apis/im/v1/messages/om_123/reply") || !strings.Contains(got, `"reply_in_thread":true`) || !strings.Contains(got, `"uuid":"uuid-1"`) { + t.Fatalf("ImMessagesReply.DryRun() = %s", got) + } + }) + + t.Run("ImMessagesReply dry run uses markdown image placeholders", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "message-id": "om_123", + "markdown": "![alt](https://example.com/a.png)", + }, nil) + got := mustMarshalDryRun(t, ImMessagesReply.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"description":"dry-run uses placeholder image keys for markdown image URLs; execution downloads and uploads them before sending"`) || + !strings.Contains(got, `"msg_type":"post"`) || + !strings.Contains(got, `img_dryrun_1`) { + t.Fatalf("ImMessagesReply.DryRun() = %s", got) + } + }) + + t.Run("ImChatMessageList dry run notes p2p resolution", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "user-id": "ou_123", + "page-size": "10", + "sort": "asc", + }, nil) + d := ImChatMessageList.DryRun(context.Background(), runtime) + formatted := d.Format() + if !strings.Contains(formatted, "resolve P2P chat_id") || !strings.Contains(formatted, "container_id=%3Cresolved_chat_id%3E") { + t.Fatalf("ImChatMessageList.DryRun().Format() = %s", formatted) + } + }) +} diff --git a/shortcuts/im/convert_lib/card.go b/shortcuts/im/convert_lib/card.go new file mode 100644 index 00000000..10c2c857 --- /dev/null +++ b/shortcuts/im/convert_lib/card.go @@ -0,0 +1,1548 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package convertlib + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" +) + +// cardObj is a convenience alias for generic JSON objects. +type cardObj = map[string]interface{} + +// cardMode controls output verbosity. +type cardMode int + +const ( + cardModeConcise cardMode = 0 + cardModeDetailed cardMode = 1 +) + +// ── Constants ───────────────────────────────────────────────────────────────── + +var cardEmojiMap = map[string]string{ + "OK": "👌", + "THUMBSUP": "👍", + "SMILE": "😊", + "HEART": "❤️", + "CLAP": "👏", + "FIRE": "🔥", + "PARTY": "🎉", + "THINK": "🤔", +} + +var cardChartTypeNames = map[string]string{ + "bar": "Bar chart", + "line": "Line chart", + "pie": "Pie chart", + "area": "Area chart", + "radar": "Radar chart", + "scatter": "Scatter plot", +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +type interactiveConverter struct{} + +func (interactiveConverter) Convert(ctx *ConvertContext) string { + return convertCard(ctx.RawContent) +} + +// convertCard converts a raw interactive/card message content JSON to human-readable string. +func convertCard(raw string) string { + var parsed cardObj + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return "[interactive card]" + } + + // raw_card_content format: outer JSON has "json_card" string field + if jsonCard, ok := parsed["json_card"].(string); ok { + c := &cardConverter{mode: cardModeConcise} + if att, ok := parsed["json_attachment"].(string); ok && att != "" { + var attObj cardObj + if json.Unmarshal([]byte(att), &attObj) == nil { + c.attachment = attObj + } + } + schema := 0 + if s, ok := parsed["card_schema"].(float64); ok { + schema = int(s) + } + result := c.convert(jsonCard, schema) + if result == "" { + return "[interactive card]" + } + return result + } + + // Legacy format + return convertLegacyCard(parsed) +} + +// ── Legacy converter ────────────────────────────────────────────────────────── + +func convertLegacyCard(parsed cardObj) string { + var texts []string + + if header, ok := parsed["header"].(cardObj); ok { + if title, ok := header["title"].(cardObj); ok { + if content, ok := title["content"].(string); ok && content != "" { + texts = append(texts, "**"+content+"**") + } + } + } + + body, _ := parsed["body"].(cardObj) + var elements []interface{} + if e, ok := parsed["elements"].([]interface{}); ok { + elements = e + } else if body != nil { + if e, ok := body["elements"].([]interface{}); ok { + elements = e + } + } + legacyExtractTexts(elements, &texts) + + if len(texts) == 0 { + return "[interactive card]" + } + return strings.Join(texts, "\n") +} + +func legacyExtractTexts(elements []interface{}, out *[]string) { + for _, el := range elements { + elem, ok := el.(cardObj) + if !ok { + continue + } + tag, _ := elem["tag"].(string) + + if tag == "markdown" { + if content, ok := elem["content"].(string); ok { + *out = append(*out, content) + } + continue + } + if tag == "div" || tag == "plain_text" || tag == "lark_md" { + if text, ok := elem["text"].(cardObj); ok { + if content, ok := text["content"].(string); ok && content != "" { + *out = append(*out, content) + } + } + if content, ok := elem["content"].(string); ok && content != "" { + *out = append(*out, content) + } + } + if tag == "column_set" { + if cols, ok := elem["columns"].([]interface{}); ok { + for _, col := range cols { + if cm, ok := col.(cardObj); ok { + if elems, ok := cm["elements"].([]interface{}); ok { + legacyExtractTexts(elems, out) + } + } + } + } + } + if elems, ok := elem["elements"].([]interface{}); ok { + legacyExtractTexts(elems, out) + } + } +} + +// ── CardConverter ───────────────────────────────────────────────────────────── + +type cardConverter struct { + mode cardMode + attachment cardObj +} + +func (c *cardConverter) convert(jsonCard string, hintSchema int) string { + var card cardObj + if err := json.Unmarshal([]byte(jsonCard), &card); err != nil { + return "\n[Unable to parse card content]\n" + } + + header, _ := card["header"].(cardObj) + title := "" + if header != nil { + title = c.extractHeaderTitle(header) + } + + bodyContent := "" + if body, ok := card["body"].(cardObj); ok { + bodyContent = c.convertBody(body) + } + + var sb strings.Builder + if title != "" { + sb.WriteString("\n") + } else { + sb.WriteString("\n") + } + if bodyContent != "" { + sb.WriteString(bodyContent) + sb.WriteString("\n") + } + sb.WriteString("") + return sb.String() +} + +func (c *cardConverter) extractHeaderTitle(header cardObj) string { + if prop, ok := header["property"].(cardObj); ok { + if titleElem, ok := prop["title"]; ok { + return c.extractTextContent(titleElem) + } + } + if titleElem, ok := header["title"]; ok { + return c.extractTextContent(titleElem) + } + return "" +} + +func (c *cardConverter) convertBody(body cardObj) string { + var elements []interface{} + + if prop, ok := body["property"].(cardObj); ok { + if e, ok := prop["elements"].([]interface{}); ok && len(e) > 0 { + elements = e + } + } + if len(elements) == 0 { + if e, ok := body["elements"].([]interface{}); ok { + elements = e + } + } + + if len(elements) == 0 { + return "" + } + return c.convertElements(elements, 0) +} + +func (c *cardConverter) convertElements(elements []interface{}, depth int) string { + var results []string + for _, el := range elements { + elem, ok := el.(cardObj) + if !ok { + continue + } + if result := c.convertElement(elem, depth); result != "" { + results = append(results, result) + } + } + return strings.Join(results, "\n") +} + +func (c *cardConverter) extractProperty(elem cardObj) cardObj { + if prop, ok := elem["property"].(cardObj); ok { + return prop + } + return elem +} + +func (c *cardConverter) convertElement(elem cardObj, depth int) string { + tag, _ := elem["tag"].(string) + id, _ := elem["id"].(string) + prop := c.extractProperty(elem) + + switch tag { + case "plain_text", "text": + return c.convertPlainText(prop) + case "markdown": + return c.convertMarkdown(prop) + case "markdown_v1": + return c.convertMarkdownV1(elem, prop) + case "div": + return c.convertDiv(prop, id) + case "note": + return c.convertNote(prop) + case "hr": + return "---" + case "br": + return "\n" + case "column_set": + return c.convertColumnSet(prop, depth) + case "column": + return c.convertColumn(prop, depth) + case "person": + return c.convertPerson(prop, id) + case "person_v1": + return c.convertPersonV1(prop, id) + case "person_list": + return c.convertPersonList(prop) + case "avatar": + return c.convertAvatar(prop, id) + case "at": + return c.convertAt(prop) + case "at_all": + return "@everyone" + case "button": + return c.convertButton(prop, id) + case "actions", "action": + return c.convertActions(prop) + case "overflow": + return c.convertOverflow(prop) + case "select_static", "select_person": + return c.convertSelect(prop, id, false) + case "multi_select_static", "multi_select_person": + return c.convertSelect(prop, id, true) + case "select_img": + return c.convertSelectImg(prop, id) + case "input": + return c.convertInput(prop, id) + case "date_picker": + return c.convertDatePicker(prop, id, "date") + case "picker_time": + return c.convertDatePicker(prop, id, "time") + case "picker_datetime": + return c.convertDatePicker(prop, id, "datetime") + case "checker": + return c.convertChecker(prop, id) + case "img", "image": + return c.convertImage(prop, id) + case "img_combination": + return c.convertImgCombination(prop) + case "table": + return c.convertTable(prop) + case "chart": + return c.convertChart(prop, id) + case "audio": + return c.convertAudio(prop, id) + case "video": + return c.convertVideo(prop, id) + case "collapsible_panel": + return c.convertCollapsiblePanel(prop, id) + case "form": + return c.convertForm(prop, id) + case "interactive_container": + return c.convertInteractiveContainer(prop, id) + case "text_tag": + return c.convertTextTag(prop) + case "number_tag": + return c.convertNumberTag(prop) + case "link": + return c.convertLink(prop) + case "emoji": + return c.convertEmoji(prop) + case "local_datetime": + return c.convertLocalDatetime(prop) + case "list": + return c.convertList(prop) + case "blockquote": + return c.convertBlockquote(prop) + case "code_block": + return c.convertCodeBlock(prop) + case "code_span": + return c.convertCodeSpan(prop) + case "heading": + return c.convertHeading(prop) + case "fallback_text": + return c.convertFallbackText(prop) + case "repeat": + return c.convertRepeat(prop) + case "card_header", "custom_icon", "standard_icon": + return "" + default: + return c.convertUnknown(prop, tag) + } +} + +// ── Text extraction ─────────────────────────────────────────────────────────── + +func (c *cardConverter) extractTextContent(v interface{}) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + return s + } + m, ok := v.(cardObj) + if !ok { + return "" + } + if prop, ok := m["property"].(cardObj); ok { + return c.extractTextFromProperty(prop) + } + return c.extractTextFromProperty(m) +} + +func (c *cardConverter) extractTextFromProperty(prop cardObj) string { + // i18n content + if i18n, ok := prop["i18nContent"].(cardObj); ok { + for _, lang := range []string{"zh_cn", "en_us", "ja_jp"} { + if t, ok := i18n[lang].(string); ok && t != "" { + return t + } + } + } + if content, ok := prop["content"].(string); ok { + return content + } + if elements, ok := prop["elements"].([]interface{}); ok && len(elements) > 0 { + var texts []string + for _, el := range elements { + if t := c.extractTextContent(el); t != "" { + texts = append(texts, t) + } + } + return strings.Join(texts, "") + } + if text, ok := prop["text"].(string); ok { + return text + } + return "" +} + +// ── Element converters ──────────────────────────────────────────────────────── + +func (c *cardConverter) convertPlainText(prop cardObj) string { + content, _ := prop["content"].(string) + if content == "" { + return "" + } + return c.applyTextStyle(content, prop) +} + +func (c *cardConverter) convertMarkdown(prop cardObj) string { + if elements, ok := prop["elements"].([]interface{}); ok && len(elements) > 0 { + return c.convertMarkdownElements(elements) + } + if content, ok := prop["content"].(string); ok { + return content + } + return "" +} + +func (c *cardConverter) convertMarkdownV1(elem, prop cardObj) string { + if elements, ok := prop["elements"].([]interface{}); ok && len(elements) > 0 { + return c.convertMarkdownElements(elements) + } + if fallback, ok := elem["fallback"].(cardObj); ok { + return c.convertElement(fallback, 0) + } + if content, ok := prop["content"].(string); ok { + return content + } + return "" +} + +func (c *cardConverter) convertMarkdownElements(elements []interface{}) string { + var parts []string + for _, el := range elements { + elem, ok := el.(cardObj) + if !ok { + continue + } + if result := c.convertElement(elem, 0); result != "" { + parts = append(parts, result) + } + } + return strings.Join(parts, "") +} + +func (c *cardConverter) convertDiv(prop cardObj, _ string) string { + var results []string + + if textElem, ok := prop["text"].(cardObj); ok { + if text := c.convertElement(textElem, 0); text != "" { + if textSize, _ := textElem["text_size"].(string); textSize == "notation" { + text = "📝 " + text + } + results = append(results, text) + } + } + + if fields, ok := prop["fields"].([]interface{}); ok { + var fieldTexts []string + for _, field := range fields { + fm, ok := field.(cardObj) + if !ok { + continue + } + if te, ok := fm["text"].(cardObj); ok { + if ft := c.convertElement(te, 0); ft != "" { + fieldTexts = append(fieldTexts, ft) + } + } + } + if len(fieldTexts) > 0 { + results = append(results, strings.Join(fieldTexts, "\n")) + } + } + + if extraElem, ok := prop["extra"].(cardObj); ok { + if extra := c.convertElement(extraElem, 0); extra != "" { + results = append(results, extra) + } + } + + return strings.Join(results, "\n") +} + +func (c *cardConverter) convertNote(prop cardObj) string { + elements, _ := prop["elements"].([]interface{}) + if len(elements) == 0 { + return "" + } + var texts []string + for _, el := range elements { + elem, ok := el.(cardObj) + if !ok { + continue + } + if text := c.convertElement(elem, 0); text != "" { + texts = append(texts, text) + } + } + if len(texts) == 0 { + return "" + } + return "📝 " + strings.Join(texts, " ") +} + +func (c *cardConverter) convertLink(prop cardObj) string { + content, _ := prop["content"].(string) + if content == "" { + content = "Link" + } + urlStr := "" + if urlObj, ok := prop["url"].(cardObj); ok { + urlStr, _ = urlObj["url"].(string) + } + if urlStr != "" { + return fmt.Sprintf("[%s](%s)", escapeMDLinkText(content), urlStr) + } + return content +} + +func (c *cardConverter) convertEmoji(prop cardObj) string { + key, _ := prop["key"].(string) + if emoji, ok := cardEmojiMap[key]; ok { + return emoji + } + return ":" + key + ":" +} + +func (c *cardConverter) convertLocalDatetime(prop cardObj) string { + if ms, ok := prop["milliseconds"].(string); ok && ms != "" { + if formatted := cardFormatMillisToISO8601(ms); formatted != "" { + return formatted + } + } + fallback, _ := prop["fallbackText"].(string) + return fallback +} + +func (c *cardConverter) convertList(prop cardObj) string { + items, _ := prop["items"].([]interface{}) + if len(items) == 0 { + return "" + } + var lines []string + for _, item := range items { + im, ok := item.(cardObj) + if !ok { + continue + } + level := 0 + if l, ok := im["level"].(float64); ok { + level = int(l) + } + listType, _ := im["type"].(string) + order := 0 + if o, ok := im["order"].(float64); ok { + order = int(math.Floor(float64(o))) + } + indent := strings.Repeat(" ", level) + marker := "-" + if listType == "ol" { + marker = fmt.Sprintf("%d.", order) + } + if elements, ok := im["elements"].([]interface{}); ok { + content := c.convertMarkdownElements(elements) + lines = append(lines, fmt.Sprintf("%s%s %s", indent, marker, content)) + } + } + return strings.Join(lines, "\n") +} + +func (c *cardConverter) convertBlockquote(prop cardObj) string { + content := "" + if s, ok := prop["content"].(string); ok { + content = s + } else if elements, ok := prop["elements"].([]interface{}); ok { + content = c.convertMarkdownElements(elements) + } + if content == "" { + return "" + } + lines := strings.Split(content, "\n") + for i, line := range lines { + lines[i] = "> " + line + } + return strings.Join(lines, "\n") +} + +func (c *cardConverter) convertCodeBlock(prop cardObj) string { + language, _ := prop["language"].(string) + if language == "" { + language = "plaintext" + } + var code strings.Builder + if contents, ok := prop["contents"].([]interface{}); ok { + for _, line := range contents { + lm, ok := line.(cardObj) + if !ok { + continue + } + if lineContents, ok := lm["contents"].([]interface{}); ok { + for _, lc := range lineContents { + cm, ok := lc.(cardObj) + if !ok { + continue + } + if s, ok := cm["content"].(string); ok { + code.WriteString(s) + } + } + } + } + } + return fmt.Sprintf("```%s\n%s```", language, code.String()) +} + +func (c *cardConverter) convertCodeSpan(prop cardObj) string { + content, _ := prop["content"].(string) + return "`" + content + "`" +} + +func (c *cardConverter) convertHeading(prop cardObj) string { + level := 1 + if l, ok := prop["level"].(float64); ok { + level = int(l) + if level < 1 { + level = 1 + } + if level > 6 { + level = 6 + } + } + content := "" + if s, ok := prop["content"].(string); ok { + content = s + } else if elements, ok := prop["elements"].([]interface{}); ok { + content = c.convertMarkdownElements(elements) + } + return strings.Repeat("#", level) + " " + content +} + +func (c *cardConverter) convertFallbackText(prop cardObj) string { + if textElem, ok := prop["text"].(cardObj); ok { + return c.extractTextContent(textElem) + } + if elements, ok := prop["elements"].([]interface{}); ok { + return c.convertMarkdownElements(elements) + } + return "" +} + +func (c *cardConverter) convertTextTag(prop cardObj) string { + textElem := prop["text"] + text := c.extractTextContent(textElem) + if text == "" { + return "" + } + return "「" + text + "」" +} + +func (c *cardConverter) convertNumberTag(prop cardObj) string { + textElem := prop["text"] + text := c.extractTextContent(textElem) + if text == "" { + return "" + } + if urlObj, ok := prop["url"].(cardObj); ok { + if urlStr, ok := urlObj["url"].(string); ok && urlStr != "" { + return fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), urlStr) + } + } + return text +} + +func (c *cardConverter) convertUnknown(prop cardObj, tag string) string { + if prop != nil { + for _, path := range []string{"content", "text", "title", "label", "placeholder"} { + if v, ok := prop[path]; ok { + text := c.extractTextContent(v) + if text != "" { + return text + } + } + } + if elements, ok := prop["elements"].([]interface{}); ok && len(elements) > 0 { + return c.convertElements(elements, 0) + } + } + if c.mode == cardModeDetailed { + return fmt.Sprintf("[Unknown content](tag:%s)", tag) + } + return "[Unknown content]" +} + +func (c *cardConverter) convertColumnSet(prop cardObj, depth int) string { + columns, _ := prop["columns"].([]interface{}) + if len(columns) == 0 { + return "" + } + var results []string + for _, col := range columns { + elem, ok := col.(cardObj) + if !ok { + continue + } + if result := c.convertElement(elem, depth+1); result != "" { + results = append(results, result) + } + } + sep := "\n\n" + if allColumnsAreButtons(results) { + sep = " " + } + return strings.Join(results, sep) +} + +// allColumnsAreButtons reports whether every result looks like a button token +// (e.g. "[Text]", "[Text](url)", "[Text ✗]"). Used to decide whether +// column_set columns should be space-joined (button row) or newline-joined. +func allColumnsAreButtons(results []string) bool { + if len(results) == 0 { + return false + } + for _, r := range results { + if !strings.HasPrefix(r, "[") || strings.Contains(r, "\n") { + return false + } + } + return true +} + +func (c *cardConverter) convertColumn(prop cardObj, depth int) string { + elements, _ := prop["elements"].([]interface{}) + if len(elements) == 0 { + return "" + } + return c.convertElements(elements, depth) +} + +func (c *cardConverter) convertForm(prop cardObj, _ string) string { + var sb strings.Builder + sb.WriteString("

\n") + if elements, ok := prop["elements"].([]interface{}); ok { + sb.WriteString(c.convertElements(elements, 0)) + } + sb.WriteString("\n") + return sb.String() +} + +func (c *cardConverter) convertCollapsiblePanel(prop cardObj, _ string) string { + expanded, _ := prop["expanded"].(bool) + title := "Details" + if header, ok := prop["header"].(cardObj); ok { + if titleElem, ok := header["title"]; ok { + if t := c.extractTextContent(titleElem); t != "" { + title = t + } + } + } + + shouldExpand := expanded || c.mode == cardModeDetailed + if shouldExpand { + var sb strings.Builder + sb.WriteString("▼ " + title + "\n") + if elements, ok := prop["elements"].([]interface{}); ok { + content := c.convertElements(elements, 1) + for _, line := range strings.Split(content, "\n") { + if line != "" { + sb.WriteString(" " + line + "\n") + } + } + } + sb.WriteString("▲") + return sb.String() + } + return "▶ " + title +} + +func (c *cardConverter) convertInteractiveContainer(prop cardObj, id string) string { + urlStr := "" + if actions, ok := prop["actions"].([]interface{}); ok && len(actions) > 0 { + if action, ok := actions[0].(cardObj); ok { + if actionType, _ := action["type"].(string); actionType == "open_url" { + if actionData, ok := action["action"].(cardObj); ok { + urlStr, _ = actionData["url"].(string) + } + } + } + } + + var sb strings.Builder + sb.WriteString("\n") + if elements, ok := prop["elements"].([]interface{}); ok { + sb.WriteString(c.convertElements(elements, 0)) + } + sb.WriteString("\n") + return sb.String() +} + +func (c *cardConverter) convertRepeat(prop cardObj) string { + if elements, ok := prop["elements"].([]interface{}); ok { + return c.convertElements(elements, 0) + } + return "" +} + +func (c *cardConverter) convertButton(prop cardObj, _ string) string { + buttonText := "" + if textElem, ok := prop["text"].(cardObj); ok { + buttonText = c.extractTextContent(textElem) + } + if buttonText == "" { + buttonText = "Button" + } + + disabled, _ := prop["disabled"].(bool) + if disabled && c.mode == cardModeConcise { + return fmt.Sprintf("[%s ✗]", buttonText) + } + + if actions, ok := prop["actions"].([]interface{}); ok { + for _, action := range actions { + am, ok := action.(cardObj) + if !ok { + continue + } + if am["type"] == "open_url" { + if ad, ok := am["action"].(cardObj); ok { + if urlStr, ok := ad["url"].(string); ok && urlStr != "" { + return fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr) + } + } + } + } + } + + if disabled && c.mode == cardModeDetailed { + result := fmt.Sprintf("[%s ✗]", buttonText) + if tips, ok := prop["disabledTips"].(cardObj); ok { + if tipsText := c.extractTextContent(tips); tipsText != "" { + result += fmt.Sprintf("(tips:\"%s\")", tipsText) + } + } + return result + } + + return fmt.Sprintf("[%s]", buttonText) +} + +func (c *cardConverter) convertActions(prop cardObj) string { + actions, _ := prop["actions"].([]interface{}) + if len(actions) == 0 { + return "" + } + var results []string + for _, action := range actions { + elem, ok := action.(cardObj) + if !ok { + continue + } + if result := c.convertElement(elem, 0); result != "" { + results = append(results, result) + } + } + return strings.Join(results, " ") +} + +func (c *cardConverter) convertOverflow(prop cardObj) string { + options, _ := prop["options"].([]interface{}) + if len(options) == 0 { + return "" + } + var optTexts []string + for _, opt := range options { + om, ok := opt.(cardObj) + if !ok { + continue + } + if textElem, ok := om["text"].(cardObj); ok { + if text := c.extractTextContent(textElem); text != "" { + optTexts = append(optTexts, text) + } + } + } + return "⋮ " + strings.Join(optTexts, ", ") +} + +func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) string { + options, _ := prop["options"].([]interface{}) + + selectedValues := map[string]bool{} + if isMulti { + if vals, ok := prop["selectedValues"].([]interface{}); ok { + for _, v := range vals { + if s, ok := v.(string); ok { + selectedValues[s] = true + } + } + } + } else { + if init, ok := prop["initialOption"].(string); ok { + selectedValues[init] = true + } + if idx, ok := prop["initialIndex"].(float64); ok { + i := int(idx) + if i >= 0 && i < len(options) { + if opt, ok := options[i].(cardObj); ok { + if val, ok := opt["value"].(string); ok { + selectedValues[val] = true + } + } + } + } + } + + var optionTexts []string + hasSelected := false + for _, opt := range options { + om, ok := opt.(cardObj) + if !ok { + continue + } + optText := "" + if textElem, ok := om["text"].(cardObj); ok { + optText = c.extractTextContent(textElem) + } + if optText == "" { + optText, _ = om["value"].(string) + } + if optText == "" { + continue + } + value, _ := om["value"].(string) + if selectedValues[value] { + optText = "✓" + optText + hasSelected = true + } + optionTexts = append(optionTexts, optText) + } + + if len(optionTexts) == 0 { + placeholder := "Please select" + if phElem, ok := prop["placeholder"].(cardObj); ok { + if ph := c.extractTextContent(phElem); ph != "" { + placeholder = ph + } + } + optionTexts = append(optionTexts, placeholder+" ▼") + } else if !hasSelected { + optionTexts[len(optionTexts)-1] += " ▼" + } + + result := "{" + strings.Join(optionTexts, " / ") + "}" + if c.mode == cardModeDetailed { + var attrs []string + if isMulti { + attrs = append(attrs, "multi") + } + if strings.Contains(id, "person") { + attrs = append(attrs, "type:person") + } + if len(attrs) > 0 { + result += "(" + strings.Join(attrs, " ") + ")" + } + } + return result +} + +func (c *cardConverter) convertSelectImg(prop cardObj, _ string) string { + options, _ := prop["options"].([]interface{}) + if len(options) == 0 { + return "" + } + selectedValues := map[string]bool{} + if vals, ok := prop["selectedValues"].([]interface{}); ok { + for _, v := range vals { + if s, ok := v.(string); ok { + selectedValues[s] = true + } + } + } + var optTexts []string + for i, opt := range options { + om, ok := opt.(cardObj) + if !ok { + continue + } + value, _ := om["value"].(string) + text := fmt.Sprintf("🖼️ Image %d", i+1) + if selectedValues[value] { + text = "✓" + text + } + optTexts = append(optTexts, text) + } + return "{" + strings.Join(optTexts, " / ") + "}" +} + +func (c *cardConverter) convertInput(prop cardObj, _ string) string { + label := "" + if labelElem, ok := prop["label"].(cardObj); ok { + label = c.extractTextContent(labelElem) + } + + defaultValue, _ := prop["defaultValue"].(string) + placeholder := "" + if phElem, ok := prop["placeholder"].(cardObj); ok { + placeholder = c.extractTextContent(phElem) + } + + var result string + switch { + case defaultValue != "": + result = defaultValue + "___" + case placeholder != "": + result = placeholder + "_____" + default: + result = "_____" + } + + if label != "" { + result = label + ": " + result + } + + if inputType, _ := prop["inputType"].(string); inputType == "multiline_text" { + result = strings.ReplaceAll(result, "_____", "...") + } + return result +} + +func (c *cardConverter) convertDatePicker(prop cardObj, _ string, pickerType string) string { + var emoji, value string + switch pickerType { + case "date": + emoji = "📅" + value, _ = prop["initialDate"].(string) + case "time": + emoji = "🕐" + value, _ = prop["initialTime"].(string) + case "datetime": + emoji = "📅" + value, _ = prop["initialDatetime"].(string) + default: + emoji = "📅" + } + + if value != "" { + value = cardNormalizeTimeFormat(value) + } + if value == "" { + placeholder := "Select" + if phElem, ok := prop["placeholder"].(cardObj); ok { + if ph := c.extractTextContent(phElem); ph != "" { + placeholder = ph + } + } + value = placeholder + } + return emoji + " " + value +} + +func (c *cardConverter) convertChecker(prop cardObj, id string) string { + checked, _ := prop["checked"].(bool) + checkMark := "[ ]" + if checked { + checkMark = "[x]" + } + text := "" + if textElem, ok := prop["text"].(cardObj); ok { + text = c.extractTextContent(textElem) + } + result := checkMark + " " + text + if c.mode == cardModeDetailed && id != "" { + result += "(id:" + id + ")" + } + return result +} + +func (c *cardConverter) convertImage(prop cardObj, _ string) string { + alt := "Image" + if altElem, ok := prop["alt"].(cardObj); ok { + if altText := c.extractTextContent(altElem); altText != "" { + alt = altText + } + } + if titleElem, ok := prop["title"].(cardObj); ok { + if titleText := c.extractTextContent(titleElem); titleText != "" { + alt = titleText + } + } + + result := "🖼️ " + alt + if c.mode == cardModeDetailed { + if imageID, ok := prop["imageID"].(string); ok && imageID != "" { + if token := c.getImageToken(imageID); token != "" { + result += "(img_token:" + token + ")" + } else { + result += "(img_key:" + imageID + ")" + } + } + } + return result +} + +func (c *cardConverter) convertImgCombination(prop cardObj) string { + imgList, _ := prop["imgList"].([]interface{}) + if len(imgList) == 0 { + return "" + } + result := fmt.Sprintf("🖼️ %d image(s)", len(imgList)) + if c.mode == cardModeDetailed { + var keys []string + for _, img := range imgList { + im, ok := img.(cardObj) + if !ok { + continue + } + if imageID, ok := im["imageID"].(string); ok && imageID != "" { + keys = append(keys, imageID) + } + } + if len(keys) > 0 { + result += "(keys:" + strings.Join(keys, ",") + ")" + } + } + return result +} + +func (c *cardConverter) convertChart(prop cardObj, _ string) string { + title := "Chart" + chartType := "" + + if chartSpec, ok := prop["chartSpec"].(cardObj); ok { + if titleObj, ok := chartSpec["title"].(cardObj); ok { + if text, ok := titleObj["text"].(string); ok && text != "" { + title = text + } + } + if ct, ok := chartSpec["type"].(string); ok && ct != "" { + chartType = ct + if typeName, ok := cardChartTypeNames[ct]; ok { + title += typeName + } + } + } + + summary := c.extractChartSummary(prop, chartType) + result := "📊 " + title + if summary != "" { + result += "\nSummary: " + summary + } + return result +} + +func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) string { + chartSpec, ok := prop["chartSpec"].(cardObj) + if !ok { + return "" + } + dataObj, ok := chartSpec["data"].(cardObj) + if !ok { + return "" + } + values, ok := dataObj["values"].([]interface{}) + if !ok || len(values) == 0 { + return "" + } + + switch chartType { + case "line", "bar", "area": + xField, _ := chartSpec["xField"].(string) + yField, _ := chartSpec["yField"].(string) + if xField == "" || yField == "" { + return fmt.Sprintf("%d data point(s)", len(values)) + } + var parts []string + for _, v := range values { + vm, ok := v.(cardObj) + if !ok { + continue + } + parts = append(parts, fmt.Sprintf("%v:%v", vm[xField], vm[yField])) + } + if len(parts) > 0 { + return strings.Join(parts, ", ") + } + case "pie": + catField, _ := chartSpec["categoryField"].(string) + valField, _ := chartSpec["valueField"].(string) + if catField == "" || valField == "" { + return fmt.Sprintf("%d data point(s)", len(values)) + } + var parts []string + for _, v := range values { + vm, ok := v.(cardObj) + if !ok { + continue + } + parts = append(parts, fmt.Sprintf("%v:%v", vm[catField], vm[valField])) + } + if len(parts) > 0 { + return strings.Join(parts, ", ") + } + } + return fmt.Sprintf("%d data point(s)", len(values)) +} + +func (c *cardConverter) convertAudio(prop cardObj, _ string) string { + result := "🎵 Audio" + if c.mode == cardModeDetailed { + fileID, _ := prop["fileID"].(string) + if fileID == "" { + fileID, _ = prop["audioID"].(string) + } + if fileID != "" { + result += "(key:" + fileID + ")" + } + } + return result +} + +func (c *cardConverter) convertVideo(prop cardObj, _ string) string { + result := "🎬 Video" + if c.mode == cardModeDetailed { + fileID, _ := prop["fileID"].(string) + if fileID == "" { + fileID, _ = prop["videoID"].(string) + } + if fileID != "" { + result += "(key:" + fileID + ")" + } + } + return result +} + +func (c *cardConverter) convertTable(prop cardObj) string { + columns, _ := prop["columns"].([]interface{}) + if len(columns) == 0 { + return "" + } + rows, _ := prop["rows"].([]interface{}) + + var colNames, colKeys []string + for _, col := range columns { + cm, ok := col.(cardObj) + if !ok { + continue + } + displayName, _ := cm["displayName"].(string) + name, _ := cm["name"].(string) + if displayName == "" { + displayName = name + } + colNames = append(colNames, displayName) + colKeys = append(colKeys, name) + } + + var lines []string + lines = append(lines, "| "+strings.Join(colNames, " | ")+" |") + separator := "|" + for range colNames { + separator += "------|" + } + lines = append(lines, separator) + + for _, row := range rows { + rm, ok := row.(cardObj) + if !ok { + continue + } + var cells []string + for _, key := range colKeys { + cellValue := "" + if cellData, ok := rm[key].(cardObj); ok { + if cellData["data"] != nil { + cellValue = c.extractTableCellValue(cellData["data"]) + } + } + cells = append(cells, cellValue) + } + lines = append(lines, "| "+strings.Join(cells, " | ")+" |") + } + return strings.Join(lines, "\n") +} + +func (c *cardConverter) extractTableCellValue(data interface{}) string { + switch v := data.(type) { + case string: + return v + case float64: + return strconv.FormatFloat(v, 'f', 2, 64) + case []interface{}: + var texts []string + for _, item := range v { + im, ok := item.(cardObj) + if !ok { + continue + } + if text, ok := im["text"].(string); ok { + texts = append(texts, "「"+text+"」") + } + } + return strings.Join(texts, " ") + default: + if m, ok := data.(cardObj); ok { + return c.extractTextContent(m) + } + return "" + } +} + +func (c *cardConverter) convertPerson(prop cardObj, _ string) string { + userID, _ := prop["userID"].(string) + if userID == "" { + return "" + } + personName := c.lookupPersonName(userID) + if personName == "" { + if notation, ok := prop["notation"].(cardObj); ok { + personName = c.extractTextContent(notation) + } + } + if personName != "" { + if c.mode == cardModeDetailed { + return fmt.Sprintf("@%s(open_id:%s)", personName, userID) + } + return "@" + personName + } + if c.mode == cardModeDetailed { + return fmt.Sprintf("@user(open_id:%s)", userID) + } + return "@" + userID +} + +// convertPersonV1 handles the v1 card schema person element. +// [#20] NOTE: this function duplicates ~20 lines from convertPerson with the only difference +// being the absence of the `notation` fallback block. Ideally it should delegate to +// convertPerson, but doing so would introduce the notation fallback for v1 schema elements +// (subtle behavior change). Not merged to preserve identical output behavior. +func (c *cardConverter) convertPersonV1(prop cardObj, _ string) string { + userID, _ := prop["userID"].(string) + if userID == "" { + return "" + } + personName := c.lookupPersonName(userID) + if personName != "" { + if c.mode == cardModeDetailed { + return fmt.Sprintf("@%s(open_id:%s)", personName, userID) + } + return "@" + personName + } + if c.mode == cardModeDetailed { + return fmt.Sprintf("@user(open_id:%s)", userID) + } + return "@" + userID +} + +func (c *cardConverter) convertPersonList(prop cardObj) string { + persons, _ := prop["persons"].([]interface{}) + if len(persons) == 0 { + return "" + } + var names []string + for _, person := range persons { + pm, ok := person.(cardObj) + if !ok { + continue + } + personID, _ := pm["id"].(string) + if c.mode == cardModeDetailed && personID != "" { + names = append(names, fmt.Sprintf("@user(id:%s)", personID)) + } else { + names = append(names, "@user") + } + } + return strings.Join(names, ", ") +} + +func (c *cardConverter) convertAvatar(prop cardObj, _ string) string { + userID, _ := prop["userID"].(string) + result := "👤" + if c.mode == cardModeDetailed && userID != "" { + result += "(id:" + userID + ")" + } + return result +} + +func (c *cardConverter) convertAt(prop cardObj) string { + userID, _ := prop["userID"].(string) + if userID == "" { + return "" + } + userName := "" + actualUserID := "" + if c.attachment != nil { + if atUsers, ok := c.attachment["at_users"].(cardObj); ok { + if userInfo, ok := atUsers[userID].(cardObj); ok { + userName, _ = userInfo["content"].(string) + actualUserID, _ = userInfo["user_id"].(string) + } + } + } + if userName != "" { + if c.mode == cardModeDetailed { + if actualUserID != "" { + return fmt.Sprintf("@%s(user_id:%s)", userName, actualUserID) + } + return fmt.Sprintf("@%s(open_id:%s)", userName, userID) + } + return "@" + userName + } + if c.mode == cardModeDetailed { + if actualUserID != "" { + return fmt.Sprintf("@user(user_id:%s)", actualUserID) + } + return fmt.Sprintf("@user(open_id:%s)", userID) + } + return "@" + userID +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func (c *cardConverter) lookupPersonName(userID string) string { + if c.attachment == nil { + return "" + } + if persons, ok := c.attachment["persons"].(cardObj); ok { + if person, ok := persons[userID].(cardObj); ok { + if content, ok := person["content"].(string); ok { + return content + } + } + } + return "" +} + +func (c *cardConverter) getImageToken(imageID string) string { + if c.attachment == nil { + return "" + } + if images, ok := c.attachment["images"].(cardObj); ok { + if imageInfo, ok := images[imageID].(cardObj); ok { + if token, ok := imageInfo["token"].(string); ok { + return token + } + } + } + return "" +} + +type cardTextStyle struct { + bold bool + italic bool + strikethrough bool +} + +func (c *cardConverter) extractTextStyle(prop cardObj) cardTextStyle { + style := cardTextStyle{} + textStyle, ok := prop["textStyle"].(cardObj) + if !ok { + return style + } + attrs, _ := textStyle["attributes"].([]interface{}) + for _, attr := range attrs { + s, ok := attr.(string) + if !ok { + continue + } + switch s { + case "bold": + style.bold = true + case "italic": + style.italic = true + case "strikethrough": + style.strikethrough = true + } + } + return style +} + +func (c *cardConverter) applyTextStyle(content string, prop cardObj) string { + if content == "" { + return content + } + style := c.extractTextStyle(prop) + if style.strikethrough { + content = "~~" + content + "~~" + } + if style.italic { + content = "*" + content + "*" + } + if style.bold { + content = "**" + content + "**" + } + return content +} + +// ── Utility functions ───────────────────────────────────────────────────────── + +func cardEscapeAttr(s string) string { + return cardAttrEscaper.Replace(s) +} + +var cardAttrEscaper = strings.NewReplacer( + `\`, `\\`, + `"`, `\"`, + "\n", `\n`, + "\r", `\r`, + "\t", `\t`, +) + +func cardFormatMillisToISO8601(ms string) string { + n, err := strconv.ParseInt(ms, 10, 64) + if err != nil { + return "" + } + t := time.Unix(n/1000, (n%1000)*int64(time.Millisecond)).UTC() + return t.Format(time.RFC3339) +} + +func cardNormalizeTimeFormat(input string) string { + if input == "" { + return "" + } + n, err := strconv.ParseInt(input, 10, 64) + if err == nil { + if len(input) >= 13 { + t := time.Unix(n/1000, (n%1000)*int64(time.Millisecond)).UTC() + return t.Format(time.RFC3339) + } else if len(input) >= 10 { + t := time.Unix(n, 0).UTC() + return t.Format(time.RFC3339) + } + } + // Already ISO8601 or date/time string + return input +} diff --git a/shortcuts/im/convert_lib/card_test.go b/shortcuts/im/convert_lib/card_test.go new file mode 100644 index 00000000..bb011f98 --- /dev/null +++ b/shortcuts/im/convert_lib/card_test.go @@ -0,0 +1,341 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package convertlib + +import ( + "strings" + "testing" +) + +func newTestCardConverter(mode cardMode) *cardConverter { + return &cardConverter{ + mode: mode, + attachment: cardObj{ + "persons": cardObj{ + "ou_person": cardObj{"content": "Alice"}, + }, + "at_users": cardObj{ + "ou_at": cardObj{"content": "Bob", "user_id": "u_bob"}, + }, + "images": cardObj{ + "img_1": cardObj{"token": "img_tok_1"}, + }, + }, + } +} + +func TestConvertCard(t *testing.T) { + rawCard := `{"json_card":"{\"schema\":1,\"header\":{\"title\":{\"content\":\"Card Title\"}},\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"hello\"}},{\"tag\":\"button\",\"property\":{\"text\":{\"content\":\"Open\"},\"actions\":[{\"type\":\"open_url\",\"action\":{\"url\":\"https://example.com\"}}]}}]}}","json_attachment":"{\"persons\":{\"ou_1\":{\"content\":\"Alice\"}}}"}` + got := convertCard(rawCard) + want := "\nhello\n[Open](https://example.com)\n" + if got != want { + t.Fatalf("convertCard(json_card) = %q, want %q", got, want) + } + + legacy := `{"header":{"title":{"content":"Legacy Card"}},"elements":[{"tag":"div","text":{"content":"legacy body"}}]}` + gotLegacy := convertCard(legacy) + wantLegacy := "**Legacy Card**\nlegacy body" + if gotLegacy != wantLegacy { + t.Fatalf("convertCard(legacy) = %q, want %q", gotLegacy, wantLegacy) + } +} + +func TestCardUtilityFunctions(t *testing.T) { + if !allColumnsAreButtons([]string{"[Open]", "[More](https://example.com)"}) { + t.Fatal("allColumnsAreButtons() = false, want true") + } + if allColumnsAreButtons([]string{"plain text", "[Open]"}) { + t.Fatal("allColumnsAreButtons() = true, want false") + } + if got := cardEscapeAttr("a\\\"b\nc\rd\t"); got != "a\\\\\\\"b\\nc\\rd\\t" { + t.Fatalf("cardEscapeAttr() = %q", got) + } + if got := cardFormatMillisToISO8601("1710500000000"); got == "" { + t.Fatal("cardFormatMillisToISO8601() returned empty") + } + if got := cardNormalizeTimeFormat("1710500000"); got == "1710500000" { + t.Fatalf("cardNormalizeTimeFormat() did not normalize seconds: %q", got) + } + if got := cardNormalizeTimeFormat("2026-03-23"); got != "2026-03-23" { + t.Fatalf("cardNormalizeTimeFormat() = %q, want original value", got) + } +} + +func TestCardConverterMethods(t *testing.T) { + c := newTestCardConverter(cardModeDetailed) + + if got := c.convertLink(cardObj{"content": "Spec", "url": cardObj{"url": "https://example.com"}}); got != "[Spec](https://example.com)" { + t.Fatalf("convertLink() = %q", got) + } + if got := c.convertMarkdown(cardObj{"content": "**bold**"}); got != "**bold**" { + t.Fatalf("convertMarkdown() = %q", got) + } + if got := c.convertMarkdownV1(cardObj{"fallback": cardObj{"tag": "text", "property": cardObj{"content": "fallback"}}}, cardObj{}); got != "fallback" { + t.Fatalf("convertMarkdownV1() = %q", got) + } + if got := c.convertDiv(cardObj{ + "text": cardObj{"tag": "text", "property": cardObj{"content": "Title"}, "text_size": "notation"}, + "fields": []interface{}{cardObj{"text": cardObj{"tag": "text", "property": cardObj{"content": "Field 1"}}}}, + "extra": cardObj{"tag": "text", "property": cardObj{"content": "Extra"}}, + }, ""); got != "📝 Title\nField 1\nExtra" { + t.Fatalf("convertDiv() = %q", got) + } + if got := c.convertNote(cardObj{"elements": []interface{}{ + cardObj{"tag": "text", "property": cardObj{"content": "Tip"}}, + cardObj{"tag": "link", "property": cardObj{"content": "Doc", "url": cardObj{"url": "https://example.com/doc"}}}, + }}); got != "📝 Tip [Doc](https://example.com/doc)" { + t.Fatalf("convertNote() = %q", got) + } + if got := c.convertEmoji(cardObj{"key": "OK"}); got != "👌" { + t.Fatalf("convertEmoji() = %q", got) + } + if got := c.convertLocalDatetime(cardObj{"milliseconds": "1710500000000"}); got == "" { + t.Fatal("convertLocalDatetime() returned empty") + } + if got := c.convertList(cardObj{"items": []interface{}{ + cardObj{"level": float64(0), "type": "ul", "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "item1"}}}}, + cardObj{"level": float64(1), "type": "ol", "order": float64(2), "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "item2"}}}}, + }}); got != "- item1\n 2. item2" { + t.Fatalf("convertList() = %q", got) + } + if got := c.convertBlockquote(cardObj{"content": "line1\nline2"}); got != "> line1\n> line2" { + t.Fatalf("convertBlockquote() = %q", got) + } + if got := c.convertCodeBlock(cardObj{"language": "go", "contents": []interface{}{ + cardObj{"contents": []interface{}{cardObj{"content": "fmt.Println(1)"}}}, + }}); got != "```go\nfmt.Println(1)```" { + t.Fatalf("convertCodeBlock() = %q", got) + } + if got := c.convertCodeSpan(cardObj{"content": "x := 1"}); got != "`x := 1`" { + t.Fatalf("convertCodeSpan() = %q", got) + } + if got := c.convertHeading(cardObj{"level": float64(2), "content": "Title"}); got != "## Title" { + t.Fatalf("convertHeading() = %q", got) + } + if got := c.convertFallbackText(cardObj{"text": cardObj{"content": "fallback"}}); got != "fallback" { + t.Fatalf("convertFallbackText() = %q", got) + } + if got := c.convertTextTag(cardObj{"text": cardObj{"content": "Tag"}}); got != "「Tag」" { + t.Fatalf("convertTextTag() = %q", got) + } + if got := c.convertNumberTag(cardObj{"text": cardObj{"content": "42"}, "url": cardObj{"url": "https://example.com/42"}}); got != "[42](https://example.com/42)" { + t.Fatalf("convertNumberTag() = %q", got) + } + if got := c.convertUnknown(cardObj{"title": cardObj{"content": "mystery"}}, "unknown"); got != "mystery" { + t.Fatalf("convertUnknown() = %q", got) + } + if got := c.convertColumnSet(cardObj{"columns": []interface{}{ + cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "A"}}}}}, + cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "B"}}}}}, + }}, 0); got != "[A] [B]" { + t.Fatalf("convertColumnSet() = %q", got) + } + if got := c.convertForm(cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "form body"}}}}, ""); got != "
\nform body\n
" { + t.Fatalf("convertForm() = %q", got) + } + if got := c.convertCollapsiblePanel(cardObj{"expanded": true, "header": cardObj{"title": cardObj{"content": "More"}}, "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "inside"}}}}, ""); got != "▼ More\n inside\n▲" { + t.Fatalf("convertCollapsiblePanel() = %q", got) + } + if got := c.convertInteractiveContainer(cardObj{"actions": []interface{}{cardObj{"type": "open_url", "action": cardObj{"url": "https://example.com"}}}, "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "Click here"}}}}, "cta_1"); got != "\nClick here\n" { + t.Fatalf("convertInteractiveContainer() = %q", got) + } + if got := c.convertRepeat(cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "repeat"}}}}); got != "repeat" { + t.Fatalf("convertRepeat() = %q", got) + } + if got := c.convertActions(cardObj{"actions": []interface{}{ + cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "One"}}}, + cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "Two"}}}, + }}); got != "[One] [Two]" { + t.Fatalf("convertActions() = %q", got) + } + if got := c.convertOverflow(cardObj{"options": []interface{}{ + cardObj{"text": cardObj{"content": "Edit"}}, + cardObj{"text": cardObj{"content": "Delete"}}, + }}); got != "⋮ Edit, Delete" { + t.Fatalf("convertOverflow() = %q", got) + } + if got := c.convertSelect(cardObj{ + "options": []interface{}{ + cardObj{"text": cardObj{"content": "Alice"}, "value": "a"}, + cardObj{"text": cardObj{"content": "Bob"}, "value": "b"}, + }, + "selectedValues": []interface{}{"a"}, + }, "select_person", true); got != "{✓Alice / Bob}(multi type:person)" { + t.Fatalf("convertSelect() = %q", got) + } + if got := c.convertSelectImg(cardObj{"options": []interface{}{cardObj{"value": "1"}, cardObj{"value": "2"}}, "selectedValues": []interface{}{"2"}}, ""); got != "{🖼️ Image 1 / ✓🖼️ Image 2}" { + t.Fatalf("convertSelectImg() = %q", got) + } + if got := c.convertInput(cardObj{"label": cardObj{"content": "Reason"}, "placeholder": cardObj{"content": "Type"}, "inputType": "multiline_text"}, ""); got != "Reason: Type..." { + t.Fatalf("convertInput() = %q", got) + } + if got := c.convertDatePicker(cardObj{"initialDate": "1710500000"}, "", "date"); got == "" || !strings.HasPrefix(got, "📅 ") { + t.Fatalf("convertDatePicker(date) = %q", got) + } + if got := c.convertChecker(cardObj{"checked": true, "text": cardObj{"content": "Done"}}, "chk_1"); got != "[x] Done(id:chk_1)" { + t.Fatalf("convertChecker() = %q", got) + } + if got := c.convertImage(cardObj{"alt": cardObj{"content": "Poster"}, "imageID": "img_1"}, ""); got != "🖼️ Poster(img_token:img_tok_1)" { + t.Fatalf("convertImage() = %q", got) + } + if got := c.convertImgCombination(cardObj{"imgList": []interface{}{cardObj{"imageID": "img_1"}, cardObj{"imageID": "img_2"}}}); got != "🖼️ 2 image(s)(keys:img_1,img_2)" { + t.Fatalf("convertImgCombination() = %q", got) + } + if got := c.convertChart(cardObj{"chartSpec": cardObj{ + "title": cardObj{"text": "Sales"}, + "type": "bar", + "xField": "month", + "yField": "value", + "data": cardObj{"values": []interface{}{ + cardObj{"month": "Jan", "value": 10}, + cardObj{"month": "Feb", "value": 20}, + }}, + }}, ""); got != "📊 SalesBar chart\nSummary: Jan:10, Feb:20" { + t.Fatalf("convertChart() = %q", got) + } + if got := c.convertAudio(cardObj{"fileID": "audio_1"}, ""); got != "🎵 Audio(key:audio_1)" { + t.Fatalf("convertAudio() = %q", got) + } + if got := c.convertVideo(cardObj{"videoID": "video_1"}, ""); got != "🎬 Video(key:video_1)" { + t.Fatalf("convertVideo() = %q", got) + } + if got := c.convertTable(cardObj{ + "columns": []interface{}{ + cardObj{"displayName": "Name", "name": "name"}, + cardObj{"displayName": "Score", "name": "score"}, + }, + "rows": []interface{}{ + cardObj{ + "name": cardObj{"data": "Alice"}, + "score": cardObj{"data": float64(95.5)}, + }, + }, + }); got != "| Name | Score |\n|------|------|\n| Alice | 95.50 |" { + t.Fatalf("convertTable() = %q", got) + } + if got := c.extractTableCellValue([]interface{}{cardObj{"text": "Tag 1"}, cardObj{"text": "Tag 2"}}); got != "「Tag 1」 「Tag 2」" { + t.Fatalf("extractTableCellValue() = %q", got) + } + if got := c.convertPerson(cardObj{"userID": "ou_person"}, ""); got != "@Alice(open_id:ou_person)" { + t.Fatalf("convertPerson() = %q", got) + } + if got := c.convertPersonV1(cardObj{"userID": "ou_person"}, ""); got != "@Alice(open_id:ou_person)" { + t.Fatalf("convertPersonV1() = %q", got) + } + if got := c.convertPersonList(cardObj{"persons": []interface{}{cardObj{"id": "u1"}, cardObj{"id": "u2"}}}); got != "@user(id:u1), @user(id:u2)" { + t.Fatalf("convertPersonList() = %q", got) + } + if got := c.convertAvatar(cardObj{"userID": "ou_person"}, ""); got != "👤(id:ou_person)" { + t.Fatalf("convertAvatar() = %q", got) + } + if got := c.convertAt(cardObj{"userID": "ou_at"}); got != "@Bob(user_id:u_bob)" { + t.Fatalf("convertAt() = %q", got) + } + if style := c.extractTextStyle(cardObj{"textStyle": cardObj{"attributes": []interface{}{"bold", "italic", "strikethrough"}}}); !style.bold || !style.italic || !style.strikethrough { + t.Fatalf("extractTextStyle() = %#v", style) + } + if got := c.applyTextStyle("hello", cardObj{"textStyle": cardObj{"attributes": []interface{}{"bold", "italic"}}}); got != "***hello***" { + t.Fatalf("applyTextStyle() = %q", got) + } + if got := (interactiveConverter{}).Convert(&ConvertContext{RawContent: `{"json_card":"{\"body\":{\"elements\":[{\"tag\":\"text\",\"property\":{\"content\":\"inside\"}}]}}"}`}); got != "\ninside\n" { + t.Fatalf("interactiveConverter.Convert() = %q", got) + } +} + +func TestCardConverterExtractTextHelpers(t *testing.T) { + c := newTestCardConverter(cardModeDetailed) + + if got := c.extractTextFromProperty(cardObj{ + "i18nContent": cardObj{ + "zh_cn": "你好", + "en_us": "hello", + }, + }); got != "你好" { + t.Fatalf("extractTextFromProperty(i18n) = %q", got) + } + + if got := c.extractTextFromProperty(cardObj{"content": "content-first"}); got != "content-first" { + t.Fatalf("extractTextFromProperty(content) = %q", got) + } + + if got := c.extractTextFromProperty(cardObj{ + "elements": []interface{}{ + cardObj{"property": cardObj{"content": "A"}}, + cardObj{"content": "B"}, + 123, + }, + }); got != "AB" { + t.Fatalf("extractTextFromProperty(elements) = %q", got) + } + + if got := c.extractTextFromProperty(cardObj{"text": "plain-text"}); got != "plain-text" { + t.Fatalf("extractTextFromProperty(text) = %q", got) + } + + if got := c.extractTextContent(cardObj{"property": cardObj{"content": "wrapped"}}); got != "wrapped" { + t.Fatalf("extractTextContent(property) = %q", got) + } + + if got := c.extractTextFromProperty(cardObj{}); got != "" { + t.Fatalf("extractTextFromProperty(empty) = %q, want empty", got) + } +} + +func TestCardConverterDispatch(t *testing.T) { + c := newTestCardConverter(cardModeDetailed) + + tests := []struct { + name string + elem cardObj + want string + contains string + }{ + {name: "plain text", elem: cardObj{"tag": "plain_text", "property": cardObj{"content": "hello"}}, want: "hello"}, + {name: "markdown", elem: cardObj{"tag": "markdown", "property": cardObj{"content": "**bold**"}}, want: "**bold**"}, + {name: "markdown v1", elem: cardObj{"tag": "markdown_v1", "fallback": cardObj{"tag": "text", "property": cardObj{"content": "fallback"}}}, want: "fallback"}, + {name: "div", elem: cardObj{"tag": "div", "property": cardObj{"text": cardObj{"tag": "text", "property": cardObj{"content": "Body"}}}}, want: "Body"}, + {name: "note", elem: cardObj{"tag": "note", "property": cardObj{"elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "Tip"}}}}}, want: "📝 Tip"}, + {name: "hr", elem: cardObj{"tag": "hr"}, want: "---"}, + {name: "br", elem: cardObj{"tag": "br"}, want: "\n"}, + {name: "column set", elem: cardObj{"tag": "column_set", "property": cardObj{"columns": []interface{}{ + cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "A"}}}}}, + cardObj{"tag": "column", "elements": []interface{}{cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "B"}}}}}, + }}}, want: "[A] [B]"}, + {name: "person", elem: cardObj{"tag": "person", "property": cardObj{"userID": "ou_person"}}, want: "@Alice(open_id:ou_person)"}, + {name: "at", elem: cardObj{"tag": "at", "property": cardObj{"userID": "ou_at"}}, want: "@Bob(user_id:u_bob)"}, + {name: "at all", elem: cardObj{"tag": "at_all"}, want: "@everyone"}, + {name: "actions", elem: cardObj{"tag": "actions", "property": cardObj{"actions": []interface{}{ + cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "One"}}}, + cardObj{"tag": "button", "property": cardObj{"text": cardObj{"content": "Two"}}}, + }}}, want: "[One] [Two]"}, + {name: "input", elem: cardObj{"tag": "input", "property": cardObj{"label": cardObj{"content": "Reason"}, "placeholder": cardObj{"content": "Type"}, "inputType": "multiline_text"}}, want: "Reason: Type..."}, + {name: "date", elem: cardObj{"tag": "date_picker", "property": cardObj{"initialDate": "1710500000"}}, contains: "📅 "}, + {name: "checker", elem: cardObj{"tag": "checker", "id": "chk_1", "property": cardObj{"checked": true, "text": cardObj{"content": "Done"}}}, want: "[x] Done(id:chk_1)"}, + {name: "image", elem: cardObj{"tag": "image", "property": cardObj{"alt": cardObj{"content": "Poster"}, "imageID": "img_1"}}, want: "🖼️ Poster(img_token:img_tok_1)"}, + {name: "interactive", elem: cardObj{"tag": "interactive_container", "id": "cta_1", "property": cardObj{ + "actions": []interface{}{cardObj{"type": "open_url", "action": cardObj{"url": "https://example.com"}}}, + "elements": []interface{}{cardObj{"tag": "text", "property": cardObj{"content": "Click here"}}}, + }}, want: "\nClick here\n"}, + {name: "text tag", elem: cardObj{"tag": "text_tag", "property": cardObj{"text": cardObj{"content": "Tag"}}}, want: "「Tag」"}, + {name: "link", elem: cardObj{"tag": "link", "property": cardObj{"content": "Spec", "url": cardObj{"url": "https://example.com"}}}, want: "[Spec](https://example.com)"}, + {name: "emoji", elem: cardObj{"tag": "emoji", "property": cardObj{"key": "OK"}}, want: "👌"}, + {name: "card header suppressed", elem: cardObj{"tag": "card_header"}, want: ""}, + {name: "unknown", elem: cardObj{"tag": "mystery", "property": cardObj{"title": cardObj{"content": "mystery"}}}, want: "mystery"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := c.convertElement(tt.elem, 0) + if tt.contains != "" { + if !strings.Contains(got, tt.contains) { + t.Fatalf("convertElement(%s) = %q, want containing %q", tt.name, got, tt.contains) + } + return + } + if got != tt.want { + t.Fatalf("convertElement(%s) = %q, want %q", tt.name, got, tt.want) + } + }) + } +} diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go new file mode 100644 index 00000000..8f9742d9 --- /dev/null +++ b/shortcuts/im/convert_lib/content_convert.go @@ -0,0 +1,190 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package convertlib + +import ( + "fmt" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ContentConverter defines the interface for converting a message type's raw content to human-readable text. +type ContentConverter interface { + Convert(ctx *ConvertContext) string +} + +// ConvertContext holds all context needed for content conversion. +type ConvertContext struct { + RawContent string + MentionMap map[string]string + // MessageID and Runtime are used by merge_forward to fetch and expand sub-messages via API. + // For other message types these can be zero values. + MessageID string + Runtime *common.RuntimeContext + // SenderNames is a shared cache of open_id -> display name, accumulated across messages + // to avoid redundant contact API calls. May be nil. + SenderNames map[string]string +} + +// converters maps message types to their ContentConverter implementations. +var converters map[string]ContentConverter + +func init() { + converters = map[string]ContentConverter{ + "text": textConverter{}, + "post": postConverter{}, + "image": imageConverter{}, + "file": fileConverter{}, + "audio": audioMsgConverter{}, + "video": videoMsgConverter{}, + "media": videoMsgConverter{}, + "sticker": stickerConverter{}, + "interactive": interactiveConverter{}, + "share_chat": shareChatConverter{}, + "share_user": shareUserConverter{}, + "location": locationConverter{}, + "merge_forward": mergeForwardConverter{}, + "folder": folderConverter{}, + "share_calendar_event": calendarEventConverter{}, + "calendar": calendarInviteConverter{}, + "general_calendar": generalCalendarConverter{}, + "video_chat": videoChatConverter{}, + "system": systemConverter{}, + "todo": todoConverter{}, + "vote": voteConverter{}, + "hongbao": hongbaoConverter{}, + } +} + +// ConvertBodyContent converts body.content (a raw JSON string) to human-readable text. +func ConvertBodyContent(msgType string, ctx *ConvertContext) string { + if ctx.RawContent == "" { + return "" + } + if c, ok := converters[msgType]; ok { + return c.Convert(ctx) + } + return fmt.Sprintf("[%s]", msgType) +} + +// FormatEventMessage converts an event-pushed message to a human-readable map. +// Event messages have a different structure from API responses: +// - message_type (not msg_type), content is a direct JSON string (not under body.content) +// - mentions are nested under message.mentions +// +// This is the entry point for im.message.receive_v1 event processors. +func FormatEventMessage(msgType, rawContent, messageID string, mentions []interface{}) map[string]interface{} { + content := ConvertBodyContent(msgType, &ConvertContext{ + RawContent: rawContent, + MentionMap: BuildMentionKeyMap(mentions), + MessageID: messageID, + }) + + msg := map[string]interface{}{ + "msg_type": msgType, + "content": content, + } + + if len(mentions) > 0 { + simplified := make([]map[string]interface{}, 0, len(mentions)) + for _, raw := range mentions { + item, _ := raw.(map[string]interface{}) + key, _ := item["key"].(string) + name, _ := item["name"].(string) + simplified = append(simplified, map[string]interface{}{ + "key": key, + "id": extractMentionOpenId(item["id"]), + "name": name, + }) + } + msg["mentions"] = simplified + } + + return msg +} + +// FormatMessageItem converts a raw API message item to a human-readable map. +// senderNames is an optional shared cache (open_id -> name) accumulated across messages; +// pass nil to disable sender name caching. +func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, senderNames ...map[string]string) map[string]interface{} { + var nameCache map[string]string + if len(senderNames) > 0 { + nameCache = senderNames[0] + } + msgType, _ := m["msg_type"].(string) + messageId, _ := m["message_id"].(string) + mentions, _ := m["mentions"].([]interface{}) + deleted, _ := m["deleted"].(bool) + updated, _ := m["updated"].(bool) + + content := "" + if body, ok := m["body"].(map[string]interface{}); ok { + rawContent, _ := body["content"].(string) + content = ConvertBodyContent(msgType, &ConvertContext{ + RawContent: rawContent, + MentionMap: BuildMentionKeyMap(mentions), + MessageID: messageId, + Runtime: runtime, + SenderNames: nameCache, + }) + } + + msg := map[string]interface{}{ + "message_id": messageId, + "msg_type": msgType, + "content": content, + "sender": m["sender"], + "create_time": common.FormatTime(m["create_time"]), + "deleted": deleted, + "updated": updated, + } + + // thread_id takes priority; fall back to reply_to (parent_id) if no thread + if tid, _ := m["thread_id"].(string); tid != "" { + msg["thread_id"] = tid + } else if pid, _ := m["parent_id"].(string); pid != "" { + msg["reply_to"] = pid + } + + if len(mentions) > 0 { + simplified := make([]map[string]interface{}, 0, len(mentions)) + for _, raw := range mentions { + item, _ := raw.(map[string]interface{}) + key, _ := item["key"].(string) + name, _ := item["name"].(string) + simplified = append(simplified, map[string]interface{}{ + "key": key, + "id": extractMentionOpenId(item["id"]), + "name": name, + }) + } + msg["mentions"] = simplified + } + + return msg +} + +// extractMentionOpenId extracts open_id from mention id (string or {"open_id":...} object). +func extractMentionOpenId(id interface{}) string { + if s, ok := id.(string); ok { + return s + } + if m, ok := id.(map[string]interface{}); ok { + if openId, ok := m["open_id"].(string); ok { + return openId + } + } + return "" +} + +// TruncateContent truncates a string for table display. +func TruncateContent(s string, max int) string { + s = strings.ReplaceAll(s, "\n", " ") + runes := []rune(s) + if len(runes) <= max { + return s + } + return string(runes[:max]) + "…" +} diff --git a/shortcuts/im/convert_lib/content_media_misc_test.go b/shortcuts/im/convert_lib/content_media_misc_test.go new file mode 100644 index 00000000..a36b7a0b --- /dev/null +++ b/shortcuts/im/convert_lib/content_media_misc_test.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package convertlib + +import ( + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +func TestConvertBodyContent(t *testing.T) { + ctx := &ConvertContext{RawContent: `{"text":"hello"}`} + + if got := ConvertBodyContent("text", ctx); got != "hello" { + t.Fatalf("ConvertBodyContent(text) = %q, want %q", got, "hello") + } + if got := ConvertBodyContent("unknown_type", ctx); got != "[unknown_type]" { + t.Fatalf("ConvertBodyContent(unknown) = %q, want %q", got, "[unknown_type]") + } + if got := ConvertBodyContent("text", &ConvertContext{}); got != "" { + t.Fatalf("ConvertBodyContent(empty) = %q, want empty", got) + } +} + +func TestFormatMessageItem(t *testing.T) { + raw := map[string]interface{}{ + "msg_type": "text", + "message_id": "om_123", + "deleted": true, + "updated": true, + "thread_id": "omt_1", + "create_time": "1710500000", + "sender": map[string]interface{}{ + "id": "ou_sender", + "sender_type": "user", + }, + "mentions": []interface{}{ + map[string]interface{}{"key": "@_user_1", "id": map[string]interface{}{"open_id": "ou_alice"}, "name": "Alice"}, + }, + "body": map[string]interface{}{ + "content": `{"text":"hi @_user_1"}`, + }, + } + + got := FormatMessageItem(raw, nil) + if got["message_id"] != "om_123" { + t.Fatalf("FormatMessageItem() message_id = %#v", got["message_id"]) + } + if got["content"] != "hi @Alice" { + t.Fatalf("FormatMessageItem() content = %#v, want %#v", got["content"], "hi @Alice") + } + if got["create_time"] != common.FormatTime("1710500000") { + t.Fatalf("FormatMessageItem() create_time = %#v, want %#v", got["create_time"], common.FormatTime("1710500000")) + } + if got["thread_id"] != "omt_1" { + t.Fatalf("FormatMessageItem() thread_id = %#v, want %#v", got["thread_id"], "omt_1") + } + mentions, _ := got["mentions"].([]map[string]interface{}) + if len(mentions) != 1 || mentions[0]["id"] != "ou_alice" { + t.Fatalf("FormatMessageItem() mentions = %#v", got["mentions"]) + } +} + +func TestExtractMentionOpenIdAndTruncateContent(t *testing.T) { + if got := extractMentionOpenId("ou_1"); got != "ou_1" { + t.Fatalf("extractMentionOpenId(string) = %q", got) + } + if got := extractMentionOpenId(map[string]interface{}{"open_id": "ou_2"}); got != "ou_2" { + t.Fatalf("extractMentionOpenId(map) = %q", got) + } + if got := extractMentionOpenId(123); got != "" { + t.Fatalf("extractMentionOpenId(other) = %q, want empty", got) + } + + if got := TruncateContent("hello\nworld", 20); got != "hello world" { + t.Fatalf("TruncateContent(no truncate) = %q", got) + } + if got := TruncateContent("你好世界和平", 4); got != "你好世界…" { + t.Fatalf("TruncateContent(truncate) = %q", got) + } +} + +func TestMediaConverters(t *testing.T) { + if got := (imageConverter{}).Convert(&ConvertContext{RawContent: `{"image_key":"img_1"}`}); got != "[Image: img_1]" { + t.Fatalf("imageConverter.Convert() = %q", got) + } + if got := (imageConverter{}).Convert(&ConvertContext{RawContent: `{invalid`}); got != "[Invalid image JSON]" { + t.Fatalf("imageConverter.Convert(invalid) = %q", got) + } + if got := (fileConverter{}).Convert(&ConvertContext{RawContent: `{"file_key":"file_1","file_name":"demo.pdf"}`}); got != `` { + t.Fatalf("fileConverter.Convert() = %q", got) + } + if got := (fileConverter{}).Convert(&ConvertContext{RawContent: `{"file_key":"file_\"1","file_name":"demo\\\".pdf"}`}); got != `` { + t.Fatalf("fileConverter.Convert(escaped) = %q", got) + } + if got := (audioMsgConverter{}).Convert(&ConvertContext{RawContent: `{"duration":3500}`}); got != "[Voice: 4s]" { + t.Fatalf("audioMsgConverter.Convert() = %q", got) + } + if got := (videoMsgConverter{}).Convert(&ConvertContext{RawContent: `{"file_key":"file_2","file_name":"clip.mp4","duration":5000,"image_key":"img_cover"}`}); got != `
with this class. +// Exported so that mail_quote.go (the generator) and projection.go +// (the detector) share a single source of truth. +const QuoteWrapperClass = "history-quote-wrapper" + +// quoteWrapperRe matches an actual
element whose class attribute +// contains QuoteWrapperClass. This avoids false positives when the +// string appears as plain text, inside
 blocks, or in
+// HTML-escaped content.
+//
+// Matches:
+//   - 
(reply) +// -
(forward) +var quoteWrapperRe = regexp.MustCompile(`]*class="[^"]*` + QuoteWrapperClass + `[^"]*"`) + +var cidRefRegexp = regexp.MustCompile(`(?i)cid:([^"' >]+)`) + +func Project(snapshot *DraftSnapshot) DraftProjection { + proj := DraftProjection{ + Subject: snapshot.Subject, + To: append([]Address{}, snapshot.To...), + Cc: append([]Address{}, snapshot.Cc...), + Bcc: append([]Address{}, snapshot.Bcc...), + ReplyTo: append([]Address{}, snapshot.ReplyTo...), + InReplyTo: snapshot.InReplyTo, + References: snapshot.References, + } + + if part := findPart(snapshot.Body, snapshot.PrimaryTextPartID); part != nil { + proj.BodyText = string(part.Body) + } + if part := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID); part != nil { + html := string(part.Body) + proj.BodyHTMLSummary = summarizeHTML(html) + proj.HasQuotedContent = hasQuotedContent(html) + } + + parts := flattenParts(snapshot.Body) + inlineCIDs := make(map[string]bool) + for _, part := range parts { + if part == nil || part.IsMultipart() { + continue + } + if part.EncodingProblem { + name := part.FileName() + if name == "" { + name = part.PartID + } + proj.Warnings = append(proj.Warnings, + "part "+name+" has encoding problems; its content may be degraded (e.g. malformed base64, bad charset, or unparseable Content-Type)") + } + summary := PartSummary{ + PartID: part.PartID, + FileName: part.FileName(), + ContentType: part.MediaType, + Disposition: part.ContentDisposition, + CID: part.ContentID, + } + switch { + case strings.EqualFold(part.ContentDisposition, "attachment"): + proj.AttachmentsSummary = append(proj.AttachmentsSummary, summary) + case strings.EqualFold(part.ContentDisposition, "inline") || part.ContentID != "": + proj.InlineSummary = append(proj.InlineSummary, summary) + if part.ContentID != "" { + inlineCIDs[strings.ToLower(part.ContentID)] = true + } + } + } + + if part := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID); part != nil { + for _, cid := range extractCIDRefs(string(part.Body)) { + if !inlineCIDs[strings.ToLower(cid)] { + proj.Warnings = append(proj.Warnings, "missing inline MIME part for cid:"+cid) + } + } + } + + return proj +} + +func flattenParts(root *Part) []*Part { + if root == nil { + return nil + } + out := []*Part{root} + for _, child := range root.Children { + out = append(out, flattenParts(child)...) + } + return out +} + +func extractCIDRefs(html string) []string { + matches := cidRefRegexp.FindAllStringSubmatch(html, -1) + if len(matches) == 0 { + return nil + } + out := make([]string, 0, len(matches)) + seen := make(map[string]bool, len(matches)) + for _, match := range matches { + cid := strings.Trim(strings.TrimSpace(match[1]), "<>") + key := strings.ToLower(cid) + if cid == "" || seen[key] { + continue + } + seen[key] = true + out = append(out, cid) + } + return out +} + +// hasQuotedContent reports whether the HTML body contains a reply or +// forward quote block generated by the Lark mail composer. +// Uses regex to match an actual
element with the class attribute, +// avoiding false positives from plain-text or code-snippet occurrences. +func hasQuotedContent(html string) bool { + return quoteWrapperRe.MatchString(html) +} + +// splitAtQuote splits an HTML body into the user-authored content and +// the trailing reply/forward quote block. If no quote block is found, +// quote is empty and body is the original html unchanged. +func splitAtQuote(html string) (body, quote string) { + loc := quoteWrapperRe.FindStringIndex(html) + if loc == nil { + return html, "" + } + return html[:loc[0]], html[loc[0]:] +} + +func summarizeHTML(html string) string { + trimmed := strings.TrimSpace(html) + runes := []rune(trimmed) + if len(runes) <= 240 { + return trimmed + } + return string(runes[:240]) + "..." +} diff --git a/shortcuts/mail/draft/projection_extra_test.go b/shortcuts/mail/draft/projection_extra_test.go new file mode 100644 index 00000000..c7318a92 --- /dev/null +++ b/shortcuts/mail/draft/projection_extra_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package draft + +import ( + "testing" +) + +// --------------------------------------------------------------------------- +// Projection with attachment summaries +// --------------------------------------------------------------------------- + +func TestProjectAttachmentSummary(t *testing.T) { + snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/forward_draft.eml")) + proj := Project(snapshot) + + if len(proj.AttachmentsSummary) == 0 { + t.Fatalf("AttachmentsSummary should not be empty for forward_draft") + } + found := false + for _, att := range proj.AttachmentsSummary { + if att.FileName != "" { + found = true + } + } + if !found { + t.Fatalf("expected at least one attachment with filename") + } +} + +// --------------------------------------------------------------------------- +// Projection with plain-text only draft +// --------------------------------------------------------------------------- + +func TestProjectPlainTextDraft(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Plain +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +This is a plain text body. +`) + proj := Project(snapshot) + if proj.Subject != "Plain" { + t.Fatalf("Subject = %q", proj.Subject) + } + if proj.BodyText != "This is a plain text body.\n" { + t.Fatalf("BodyText = %q", proj.BodyText) + } + if proj.BodyHTMLSummary != "" { + t.Fatalf("BodyHTMLSummary should be empty for plain-text draft") + } +} + +// --------------------------------------------------------------------------- +// Projection with encoding problem +// --------------------------------------------------------------------------- + +func TestProjectEncodingProblemWarning(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Bad +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary=mix + +--mix +Content-Type: text/plain; charset=UTF-8 + +hello +--mix +Content-Type: application/pdf; name=report.pdf +Content-Disposition: attachment; filename=report.pdf +Content-Transfer-Encoding: base64 + +!!!not-valid-base64!!! +--mix-- +`) + proj := Project(snapshot) + foundWarning := false + for _, w := range proj.Warnings { + if len(w) > 0 { + foundWarning = true + } + } + if !foundWarning { + t.Fatalf("expected encoding problem warning, Warnings = %v", proj.Warnings) + } +} + +// --------------------------------------------------------------------------- +// Projection truncates long HTML +// --------------------------------------------------------------------------- + +func TestProjectHTMLSummaryTruncation(t *testing.T) { + longHTML := "

" + string(make([]byte, 500)) + "

" + snapshot := mustParseFixtureDraft(t, `Subject: Long +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +`+longHTML+` +`) + proj := Project(snapshot) + if len(proj.BodyHTMLSummary) > 300 { + t.Fatalf("BodyHTMLSummary len = %d, should be truncated", len(proj.BodyHTMLSummary)) + } +} diff --git a/shortcuts/mail/draft/projection_test.go b/shortcuts/mail/draft/projection_test.go new file mode 100644 index 00000000..a2e7163f --- /dev/null +++ b/shortcuts/mail/draft/projection_test.go @@ -0,0 +1,212 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package draft + +import ( + "strings" + "testing" +) + +func TestProjectInlineSummaryAndWarnings(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Inline +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/related; boundary=rel + +--rel +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +

hello

+--rel +Content-Type: image/png; name=logo.png +Content-Disposition: inline; filename=logo.png +Content-ID: +Content-Transfer-Encoding: base64 + +aGVsbG8= +--rel-- +`) + + proj := Project(snapshot) + if proj.BodyHTMLSummary == "" || !strings.Contains(proj.BodyHTMLSummary, "cid:logo") { + t.Fatalf("BodyHTMLSummary = %q", proj.BodyHTMLSummary) + } + if len(proj.InlineSummary) != 1 { + t.Fatalf("InlineSummary len = %d", len(proj.InlineSummary)) + } + if proj.InlineSummary[0].PartID != "1.2" { + t.Fatalf("InlineSummary[0].PartID = %q", proj.InlineSummary[0].PartID) + } + if len(proj.Warnings) != 0 { + t.Fatalf("Warnings = %#v", proj.Warnings) + } +} + +// --------------------------------------------------------------------------- +// HasQuotedContent detection +// --------------------------------------------------------------------------- + +func TestProjectHasQuotedContentReply(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Re: Hello +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +
My reply
quoted original
+`) + proj := Project(snapshot) + if !proj.HasQuotedContent { + t.Fatalf("HasQuotedContent = false, want true for reply draft") + } +} + +func TestProjectHasQuotedContentForward(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Fwd: Hello +From: Alice +To: Carol +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +
forwarding note
quoted content
+`) + proj := Project(snapshot) + if !proj.HasQuotedContent { + t.Fatalf("HasQuotedContent = false, want true for forward draft") + } +} + +func TestProjectHasQuotedContentPlainDraft(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Hello +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +

Just a regular draft

+`) + proj := Project(snapshot) + if proj.HasQuotedContent { + t.Fatalf("HasQuotedContent = true, want false for plain draft") + } +} + +// --------------------------------------------------------------------------- +// splitAtQuote +// --------------------------------------------------------------------------- + +func TestSplitAtQuoteReply(t *testing.T) { + html := `
My reply
quoted
` + body, quote := splitAtQuote(html) + if body != `
My reply
` { + t.Fatalf("body = %q", body) + } + if quote != `
quoted
` { + t.Fatalf("quote = %q", quote) + } +} + +func TestSplitAtQuoteForward(t *testing.T) { + html := `
note
quoted
` + body, quote := splitAtQuote(html) + if body != `
note
` { + t.Fatalf("body = %q", body) + } + if !strings.Contains(quote, "history-quote-wrapper") { + t.Fatalf("quote = %q, want to contain history-quote-wrapper", quote) + } +} + +func TestSplitAtQuoteNoQuote(t *testing.T) { + html := `
no quote here
` + body, quote := splitAtQuote(html) + if body != html { + t.Fatalf("body = %q, want original html", body) + } + if quote != "" { + t.Fatalf("quote = %q, want empty", quote) + } +} + +// --------------------------------------------------------------------------- +// False-positive resistance: plain text / code containing the class name +// --------------------------------------------------------------------------- + +func TestProjectHasQuotedContentFalsePositivePlainText(t *testing.T) { + // The class name appears as plain text, not as an actual
attribute. + snapshot := mustParseFixtureDraft(t, `Subject: About CSS +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +

The class is called history-quote-wrapper and it wraps the quote.

+`) + proj := Project(snapshot) + if proj.HasQuotedContent { + t.Fatalf("HasQuotedContent = true, want false for plain-text mention of class name") + } +} + +func TestProjectHasQuotedContentFalsePositiveCodeBlock(t *testing.T) { + // The class name appears inside a
 code block, not as a real div.
+	snapshot := mustParseFixtureDraft(t, `Subject: Code review
+From: Alice 
+To: Bob 
+MIME-Version: 1.0
+Content-Type: text/html; charset=UTF-8
+
+
class="history-quote-wrapper"
+`) + proj := Project(snapshot) + if proj.HasQuotedContent { + t.Fatalf("HasQuotedContent = true, want false for code block containing class name") + } +} + +func TestSplitAtQuoteFalsePositivePlainText(t *testing.T) { + html := `

The CSS class history-quote-wrapper is used for quotes.

` + body, quote := splitAtQuote(html) + if body != html { + t.Fatalf("body should be unchanged, got %q", body) + } + if quote != "" { + t.Fatalf("quote should be empty for false positive, got %q", quote) + } +} + +func TestParseMissingInlineCIDReportedAsProjectionWarning(t *testing.T) { + // Missing CID references should NOT prevent parsing; they are reported + // as warnings in Project() instead. + snapshot, err := Parse(DraftRaw{ + DraftID: "d-1", + RawEML: encodeFixtureEML(`Subject: Inline +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +

hello

+`), + }) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + proj := Project(snapshot) + if len(proj.Warnings) == 0 { + t.Fatalf("expected warning for missing cid, got none") + } + found := false + for _, w := range proj.Warnings { + if strings.Contains(w, "missing") { + found = true + } + } + if !found { + t.Fatalf("expected warning about missing cid, got %v", proj.Warnings) + } +} diff --git a/shortcuts/mail/draft/serialize.go b/shortcuts/mail/draft/serialize.go new file mode 100644 index 00000000..484af524 --- /dev/null +++ b/shortcuts/mail/draft/serialize.go @@ -0,0 +1,337 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package draft + +import ( + "bytes" + "encoding/base64" + "fmt" + "math/rand" + "mime" + "mime/quotedprintable" + "strings" +) + +func Serialize(snapshot *DraftSnapshot) (string, error) { + if snapshot == nil || snapshot.Body == nil { + return "", fmt.Errorf("draft snapshot is empty") + } + var buf bytes.Buffer + mimeVersionValue := "1.0" + wroteMimeVersion := false + for _, header := range snapshot.Headers { + if strings.EqualFold(header.Name, "MIME-Version") { + mimeVersionValue = header.Value + writeHeader(&buf, header.Name, header.Value) + wroteMimeVersion = true + continue + } + if isBodyHeader(header.Name) { + continue + } + writeHeader(&buf, header.Name, header.Value) + } + if !wroteMimeVersion { + writeHeader(&buf, "MIME-Version", mimeVersionValue) + } + if err := writeTopLevelBody(&buf, snapshot.Body); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(buf.Bytes()), nil +} + +func writeTopLevelBody(buf *bytes.Buffer, root *Part) error { + if canReuseRawEntity(root) { + buf.Write(root.RawEntity) + if len(root.RawEntity) == 0 || root.RawEntity[len(root.RawEntity)-1] != '\n' { + buf.WriteByte('\n') + } + return nil + } + if root.IsMultipart() { + for _, header := range orderedPartHeaders(root, false) { + writeHeader(buf, header.Name, header.Value) + } + buf.WriteByte('\n') + return writeMultipartBody(buf, root) + } + for _, header := range orderedPartHeaders(root, true) { + writeHeader(buf, header.Name, header.Value) + } + buf.WriteByte('\n') + return writeLeafBody(buf, root) +} + +func writeMultipartBody(buf *bytes.Buffer, part *Part) error { + boundary := part.MediaParams["boundary"] + if boundary == "" { + boundary = newBoundary() + part.MediaParams["boundary"] = boundary + } + if len(part.Preamble) > 0 { + buf.Write(part.Preamble) + if part.Preamble[len(part.Preamble)-1] != '\n' { + buf.WriteByte('\n') + } + } + for _, child := range part.Children { + if child == nil { + continue + } + fmt.Fprintf(buf, "--%s\n", boundary) + if canReuseRawEntity(child) { + buf.Write(child.RawEntity) + if n := len(child.RawEntity); n == 0 || child.RawEntity[n-1] != '\n' { + buf.WriteByte('\n') + } + continue + } + if child.IsMultipart() { + for _, header := range orderedPartHeaders(child, false) { + writeHeader(buf, header.Name, header.Value) + } + buf.WriteByte('\n') + if err := writeMultipartBody(buf, child); err != nil { + return err + } + continue + } + for _, header := range orderedPartHeaders(child, true) { + writeHeader(buf, header.Name, header.Value) + } + buf.WriteByte('\n') + if err := writeLeafBody(buf, child); err != nil { + return err + } + } + fmt.Fprintf(buf, "--%s--\n", boundary) + if len(part.Epilogue) > 0 { + buf.Write(part.Epilogue) + if part.Epilogue[len(part.Epilogue)-1] != '\n' { + buf.WriteByte('\n') + } + } + return nil +} + +func orderedPartHeaders(part *Part, includeCTE bool) []Header { + contentTypeValue := existingHeaderValue(part.Headers, "Content-Type") + if contentTypeValue == "" { + contentTypeValue = mime.FormatMediaType(part.MediaType, cloneStringMap(part.MediaParams)) + } + + headers := make([]Header, 0, len(part.Headers)+4) + replacements := map[string]Header{ + "content-type": { + Name: "Content-Type", + Value: contentTypeValue, + }, + } + if includeCTE { + if cte := chooseTransferEncoding(part); cte != "" { + value := cte + if existing := existingHeaderValue(part.Headers, "Content-Transfer-Encoding"); strings.EqualFold(existing, cte) { + value = existing + } + replacements["content-transfer-encoding"] = Header{ + Name: "Content-Transfer-Encoding", + Value: value, + } + } + } + if part.ContentDisposition != "" { + value := existingHeaderValue(part.Headers, "Content-Disposition") + if value == "" { + value = mime.FormatMediaType(part.ContentDisposition, cloneStringMap(part.ContentDispositionArg)) + } + replacements["content-disposition"] = Header{ + Name: "Content-Disposition", + Value: value, + } + } + if part.ContentID != "" { + value := existingHeaderValue(part.Headers, "Content-ID") + if value == "" { + value = "<" + part.ContentID + ">" + } + replacements["content-id"] = Header{Name: "Content-ID", Value: value} + } + + written := make(map[string]bool, len(replacements)) + for _, header := range part.Headers { + name := strings.ToLower(header.Name) + switch name { + case "mime-version": + continue + case "content-type", "content-transfer-encoding", "content-disposition", "content-id": + if replacement, ok := replacements[name]; ok { + replacement.Name = header.Name + headers = append(headers, replacement) + written[name] = true + } + default: + headers = append(headers, header) + } + } + for _, key := range []string{"content-type", "content-transfer-encoding", "content-disposition", "content-id"} { + if written[key] { + continue + } + if replacement, ok := replacements[key]; ok { + headers = append(headers, replacement) + } + } + return headers +} + +func chooseTransferEncoding(part *Part) string { + if part.IsMultipart() { + return "" + } + switch { + case part.ContentDisposition == "attachment": + return "base64" + case strings.HasPrefix(part.MediaType, "text/"): + switch strings.ToLower(strings.TrimSpace(part.TransferEncoding)) { + case "quoted-printable": + return "quoted-printable" + case "base64": + if hasNonASCII(part.Body) { + return "base64" + } + } + if hasNonASCII(part.Body) { + return "quoted-printable" + } + return "7bit" + default: + return "base64" + } +} + +func writeLeafBody(buf *bytes.Buffer, part *Part) error { + body, err := encodedLeafBody(part) + if err != nil { + return err + } + cte := chooseTransferEncoding(part) + switch cte { + case "base64": + writeFoldedBody(buf, base64.StdEncoding.EncodeToString(body), 76) + case "quoted-printable": + writer := quotedprintable.NewWriter(buf) + if _, err := writer.Write(body); err != nil { + _ = writer.Close() + return err + } + if err := writer.Close(); err != nil { + return err + } + if buf.Len() == 0 || buf.Bytes()[buf.Len()-1] != '\n' { + buf.WriteByte('\n') + } + default: + if len(body) > 0 { + buf.Write(body) + if body[len(body)-1] != '\n' { + buf.WriteByte('\n') + } + } else { + buf.WriteByte('\n') + } + } + return nil +} + +func writeFoldedBody(buf *bytes.Buffer, encoded string, width int) { + if width <= 0 { + width = 76 + } + for len(encoded) > width { + buf.WriteString(encoded[:width]) + buf.WriteByte('\n') + encoded = encoded[width:] + } + if encoded != "" { + buf.WriteString(encoded) + buf.WriteByte('\n') + } +} + +func writeHeader(buf *bytes.Buffer, name, value string) { + // Strip CR and LF as a last-resort defense against header injection. + // Callers (applyOp, Validate) already reject CR/LF explicitly; this + // sanitisation covers any path that bypasses those checks. + name = strings.NewReplacer("\r", "", "\n", "").Replace(name) + value = strings.NewReplacer("\r", "", "\n", "").Replace(value) + buf.WriteString(name) + buf.WriteString(": ") + buf.WriteString(value) + buf.WriteByte('\n') +} + +func existingHeaderValue(headers []Header, name string) string { + for _, header := range headers { + if strings.EqualFold(header.Name, name) { + return header.Value + } + } + return "" +} + +func canReuseRawEntity(part *Part) bool { + if part == nil || len(part.RawEntity) == 0 { + return false + } + return !partHasDirty(part) +} + +func partHasDirty(part *Part) bool { + if part == nil { + return false + } + if part.Dirty { + return true + } + for _, child := range part.Children { + if partHasDirty(child) { + return true + } + } + return false +} + +func hasNonASCII(body []byte) bool { + for _, b := range body { + if b > 127 { + return true + } + } + return false +} + +func encodedLeafBody(part *Part) ([]byte, error) { + if part == nil { + return nil, nil + } + if !isTextualMediaType(part.MediaType) { + return part.Body, nil + } + charsetLabel := normalizeCharsetLabel(part.MediaParams["charset"]) + if charsetLabel == "" { + charsetLabel = "UTF-8" + part.MediaParams["charset"] = charsetLabel + } + encoded, err := encodeTextCharset(part.Body, charsetLabel) + if err == nil { + return encoded, nil + } + part.MediaParams["charset"] = "UTF-8" + syncStructuredPartHeaders(part) + return part.Body, nil +} + +func newBoundary() string { + return fmt.Sprintf("lark-draft-%d-%d", rand.Int63(), rand.Int63()) +} diff --git a/shortcuts/mail/draft/serialize_golden_test.go b/shortcuts/mail/draft/serialize_golden_test.go new file mode 100644 index 00000000..83cf8be2 --- /dev/null +++ b/shortcuts/mail/draft/serialize_golden_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package draft + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSerializeGoldenFixtures(t *testing.T) { + cases := []struct { + name string + input string + golden string + patch Patch + patchFn func(*testing.T) Patch + }{ + { + name: "reply-subject", + input: "testdata/reply_draft.eml", + golden: "testdata/reply_draft_subject.golden.eml", + patch: Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated reply"}}}, + }, + { + name: "alternative-set-body", + input: "testdata/alternative_draft.eml", + golden: "testdata/alternative_set_body.golden.eml", + patch: Patch{Ops: []PatchOp{{Op: "set_body", Value: "
updated body
"}}}, + }, + { + name: "html-inline-replace", + input: "testdata/html_inline_draft.eml", + golden: "testdata/html_inline_replace.golden.eml", + patch: Patch{Ops: []PatchOp{{Op: "replace_body", BodyKind: "text/html", Selector: "primary", Value: `
updated
`}}}, + }, + { + name: "forward-remove-attachment", + input: "testdata/forward_draft.eml", + golden: "testdata/forward_remove_attachment.golden.eml", + patch: Patch{Ops: []PatchOp{{Op: "remove_attachment", Target: AttachmentTarget{PartID: "1.3"}}}}, + }, + { + name: "custom-header-preserved", + input: "testdata/custom_header_draft.eml", + golden: "testdata/custom_header_set_subject.golden.eml", + patch: Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated custom"}}}, + }, + { + name: "inline-replace-binary", + input: "testdata/html_inline_draft.eml", + golden: "testdata/html_inline_replace_binary.golden.eml", + patchFn: func(t *testing.T) Patch { + t.Helper() + chdirTemp(t) + if err := os.WriteFile("updated-inline.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + return Patch{Ops: []PatchOp{{Op: "replace_inline", Target: AttachmentTarget{PartID: "1.2"}, Path: "updated-inline.png"}}} + }, + }, + { + name: "inline-remove-with-html-update", + input: "testdata/html_inline_draft.eml", + golden: "testdata/html_inline_remove.golden.eml", + patch: Patch{Ops: []PatchOp{ + {Op: "replace_body", BodyKind: "text/html", Selector: "primary", Value: `
updated without image
`}, + {Op: "remove_inline", Target: AttachmentTarget{PartID: "1.2"}}, + }}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Read fixture files before patchFn which may chdirTemp. + input := mustReadFixture(t, tc.input) + want := mustReadFixture(t, tc.golden) + snapshot := mustParseFixtureDraft(t, input) + patch := tc.patch + if tc.patchFn != nil { + patch = tc.patchFn(t) + } + if err := Apply(snapshot, patch); err != nil { + t.Fatalf("Apply() error = %v", err) + } + raw, err := Serialize(snapshot) + if err != nil { + t.Fatalf("Serialize() error = %v", err) + } + decoded, err := decodeRawEML(raw) + if err != nil { + t.Fatalf("decodeRawEML() error = %v", err) + } + got := string(decoded) + if got != want { + t.Fatalf("golden mismatch\nwant:\n%s\n\ngot:\n%s", want, got) + } + }) + } +} + +func mustReadFixture(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + t.Fatalf("ReadFile(%q) error = %v", path, err) + } + return string(data) +} diff --git a/shortcuts/mail/draft/serialize_test.go b/shortcuts/mail/draft/serialize_test.go new file mode 100644 index 00000000..834b84cd --- /dev/null +++ b/shortcuts/mail/draft/serialize_test.go @@ -0,0 +1,260 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package draft + +import ( + "strings" + "testing" +) + +func TestSerializeRoundTripKeepsAttachmentsAndHTML(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Original +From: Alice +To: Bob +Bcc: Hidden +In-Reply-To: +References: +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary=mix + +--mix +Content-Type: multipart/alternative; boundary=alt + +--alt +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +hello +--alt +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +

hello

+--alt-- +--mix +Content-Type: application/pdf; name=report.pdf +Content-Disposition: attachment; filename=report.pdf +Content-Transfer-Encoding: base64 + +aGVsbG8= +--mix-- +`) + + err := Apply(snapshot, Patch{ + Ops: []PatchOp{ + {Op: "set_subject", Value: "Updated"}, + {Op: "set_body", Value: "
updated body
"}, + }, + }) + if err != nil { + t.Fatalf("Apply() error = %v", err) + } + serialized, err := Serialize(snapshot) + if err != nil { + t.Fatalf("Serialize() error = %v", err) + } + roundTrip, err := Parse(DraftRaw{DraftID: "d-1", RawEML: serialized}) + if err != nil { + t.Fatalf("Parse(roundTrip) error = %v", err) + } + if roundTrip.Subject != "Updated" { + t.Fatalf("Subject = %q", roundTrip.Subject) + } + if roundTrip.InReplyTo != "" { + t.Fatalf("InReplyTo = %q", roundTrip.InReplyTo) + } + if roundTrip.References != " " { + t.Fatalf("References = %q", roundTrip.References) + } + if got := string(findPart(roundTrip.Body, roundTrip.PrimaryHTMLPartID).Body); got != "
updated body
" { + t.Fatalf("HTML body = %q", got) + } + if got := string(findPart(roundTrip.Body, roundTrip.PrimaryTextPartID).Body); got != "updated body" { + t.Fatalf("text body = %q", got) + } + if attachment := findPart(roundTrip.Body, "1.2"); attachment == nil || attachment.FileName() != "report.pdf" { + t.Fatalf("attachment not preserved: %#v", attachment) + } + if got := headerValue(roundTrip.Headers, "Bcc"); got == "" { + t.Fatalf("Bcc header unexpectedly dropped") + } +} + +func TestSerializeSubjectOnlyPreservesOriginalBodyEntity(t *testing.T) { + original := `Subject: Original +From: Alice +To: Bob +Message-ID: +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary=mix + +--mix +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +hello +--mix +Content-Type: image/png; name=logo.png +Content-Transfer-Encoding: base64 +Content-Disposition: inline; filename=logo.png +Content-ID: + +aGVsbG8= +--mix-- +` + snapshot := mustParseFixtureDraft(t, original) + if err := Apply(snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated"}}}); err != nil { + t.Fatalf("Apply() error = %v", err) + } + serialized, err := Serialize(snapshot) + if err != nil { + t.Fatalf("Serialize() error = %v", err) + } + decoded, err := decodeRawEML(serialized) + if err != nil { + t.Fatalf("decodeRawEML() error = %v", err) + } + got := string(decoded) + wantIdx := strings.Index(original, "Content-Type: multipart/mixed; boundary=mix") + if wantIdx < 0 { + t.Fatal("expected Content-Type multipart/mixed not found in original") + } + gotIdx := strings.Index(got, "Content-Type: multipart/mixed; boundary=mix") + if gotIdx < 0 { + t.Fatal("expected Content-Type multipart/mixed not found in serialized output") + } + wantBodyEntity := original[wantIdx:] + gotBodyEntity := got[gotIdx:] + if gotBodyEntity != wantBodyEntity { + t.Fatalf("body entity changed unexpectedly\nwant:\n%s\n\ngot:\n%s", wantBodyEntity, gotBodyEntity) + } +} + +func TestSerializeEditedQuotedPrintableTextPreservesReadableTextSemantics(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Encoded body +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/plain; charset=ISO-8859-1 +Content-Transfer-Encoding: quoted-printable + +caf=E9 +`) + if err := Apply(snapshot, Patch{ + Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: " déjà"}}, + }); err != nil { + t.Fatalf("Apply() error = %v", err) + } + serialized, err := Serialize(snapshot) + if err != nil { + t.Fatalf("Serialize() error = %v", err) + } + decoded, err := decodeRawEML(serialized) + if err != nil { + t.Fatalf("decodeRawEML() error = %v", err) + } + raw := string(decoded) + if !strings.Contains(strings.ToLower(raw), "content-transfer-encoding: quoted-printable") { + t.Fatalf("serialized raw missing quoted-printable:\n%s", raw) + } + if !strings.Contains(strings.ToLower(raw), "charset=iso-8859-1") { + t.Fatalf("serialized raw missing original charset:\n%s", raw) + } + roundTrip, err := Parse(DraftRaw{DraftID: "d-qp", RawEML: serialized}) + if err != nil { + t.Fatalf("Parse(roundTrip) error = %v", err) + } + if got := string(findPart(roundTrip.Body, roundTrip.PrimaryTextPartID).Body); got != "café\n déjà\n" { + t.Fatalf("round-trip text body = %q", got) + } +} + +func TestSerializeSubjectOnlyPreservesEmbeddedMessageAttachment(t *testing.T) { + original := mustReadFixture(t, "testdata/message_rfc822_draft.eml") + snapshot := mustParseFixtureDraft(t, original) + if err := Apply(snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated forward"}}}); err != nil { + t.Fatalf("Apply() error = %v", err) + } + serialized, err := Serialize(snapshot) + if err != nil { + t.Fatalf("Serialize() error = %v", err) + } + decoded, err := decodeRawEML(serialized) + if err != nil { + t.Fatalf("decodeRawEML() error = %v", err) + } + got := string(decoded) + if !strings.Contains(got, "Content-Type: message/rfc822; name=forwarded.eml") { + t.Fatalf("embedded message attachment missing:\n%s", got) + } + if !strings.Contains(got, "Subject: Inner message") { + t.Fatalf("embedded message payload changed unexpectedly:\n%s", got) + } +} + +func TestSerializeSubjectOnlyPreservesSignedBodyEntity(t *testing.T) { + original := mustReadFixture(t, "testdata/multipart_signed_draft.eml") + snapshot := mustParseFixtureDraft(t, original) + if err := Apply(snapshot, Patch{Ops: []PatchOp{{Op: "set_subject", Value: "Updated signed"}}}); err != nil { + t.Fatalf("Apply() error = %v", err) + } + serialized, err := Serialize(snapshot) + if err != nil { + t.Fatalf("Serialize() error = %v", err) + } + decoded, err := decodeRawEML(serialized) + if err != nil { + t.Fatalf("decodeRawEML() error = %v", err) + } + got := string(decoded) + wantIdx := strings.Index(original, "Content-Type: multipart/signed") + if wantIdx < 0 { + t.Fatal("expected Content-Type multipart/signed not found in original") + } + gotIdx := strings.Index(got, "Content-Type: multipart/signed") + if gotIdx < 0 { + t.Fatal("expected Content-Type multipart/signed not found in serialized output") + } + wantBodyEntity := original[wantIdx:] + gotBodyEntity := got[gotIdx:] + if gotBodyEntity != wantBodyEntity { + t.Fatalf("signed body entity changed unexpectedly\nwant:\n%s\n\ngot:\n%s", wantBodyEntity, gotBodyEntity) + } +} + +func TestSerializeDirtyMultipartKeepsPreambleAndEpilogue(t *testing.T) { + original := mustReadFixture(t, "testdata/dirty_multipart_preamble.eml") + snapshot := mustParseFixtureDraft(t, original) + if err := Apply(snapshot, Patch{ + Ops: []PatchOp{{Op: "append_body", BodyKind: "text/plain", Selector: "primary", Value: "\nworld"}}, + }); err != nil { + t.Fatalf("Apply() error = %v", err) + } + serialized, err := Serialize(snapshot) + if err != nil { + t.Fatalf("Serialize() error = %v", err) + } + decoded, err := decodeRawEML(serialized) + if err != nil { + t.Fatalf("decodeRawEML() error = %v", err) + } + got := string(decoded) + for _, want := range []string{ + "This is a preamble line.\nStill preamble.\n", + "--mix--\nThis is an epilogue line.\nTrailing dirty text.\n", + "cOnTeNt-TyPe: multipart/mixed; boundary=mix", + "Subject: Dirty multipart", + } { + if !strings.Contains(got, want) { + t.Fatalf("serialized multipart missing %q\n%s", want, got) + } + } + roundTrip, err := Parse(DraftRaw{DraftID: "d-dirty", RawEML: serialized}) + if err != nil { + t.Fatalf("Parse(roundTrip) error = %v", err) + } + if got := string(findPart(roundTrip.Body, roundTrip.PrimaryTextPartID).Body); got != "hello\nworld" { + t.Fatalf("round-trip text body = %q", got) + } +} diff --git a/shortcuts/mail/draft/service.go b/shortcuts/mail/draft/service.go new file mode 100644 index 00000000..e62ab680 --- /dev/null +++ b/shortcuts/mail/draft/service.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package draft + +import ( + "fmt" + "net/url" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +func mailboxPath(mailboxID string, segments ...string) string { + parts := make([]string, 0, len(segments)+1) + parts = append(parts, url.PathEscape(mailboxID)) + for _, seg := range segments { + if seg == "" { + continue + } + parts = append(parts, url.PathEscape(seg)) + } + return "/open-apis/mail/v1/user_mailboxes/" + strings.Join(parts, "/") +} + +func GetRaw(runtime *common.RuntimeContext, mailboxID, draftID string) (DraftRaw, error) { + data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "drafts", draftID), map[string]interface{}{"format": "raw"}, nil) + if err != nil { + return DraftRaw{}, err + } + raw := extractRawEML(data) + if raw == "" { + return DraftRaw{}, fmt.Errorf("API response missing draft raw EML; the backend returned an empty raw body for this draft") + } + gotDraftID := extractDraftID(data) + if gotDraftID == "" { + gotDraftID = draftID + } + return DraftRaw{ + DraftID: gotDraftID, + RawEML: raw, + }, nil +} + +func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (string, error) { + data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts"), nil, map[string]interface{}{"raw": rawEML}) + if err != nil { + return "", err + } + draftID := extractDraftID(data) + if draftID == "" { + return "", fmt.Errorf("API response missing draft_id") + } + return draftID, nil +} + +func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML string) error { + _, err := runtime.CallAPI("PUT", mailboxPath(mailboxID, "drafts", draftID), nil, map[string]interface{}{"raw": rawEML}) + return err +} + +func Send(runtime *common.RuntimeContext, mailboxID, draftID string) (map[string]interface{}, error) { + return runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts", draftID, "send"), nil, nil) +} + +func extractDraftID(data map[string]interface{}) string { + if id, ok := data["draft_id"].(string); ok && strings.TrimSpace(id) != "" { + return strings.TrimSpace(id) + } + if id, ok := data["id"].(string); ok && strings.TrimSpace(id) != "" { + return strings.TrimSpace(id) + } + if draft, ok := data["draft"].(map[string]interface{}); ok { + return extractDraftID(draft) + } + return "" +} + +func extractRawEML(data map[string]interface{}) string { + if raw, ok := data["raw"].(string); ok && strings.TrimSpace(raw) != "" { + return strings.TrimSpace(raw) + } + if msg, ok := data["message"].(map[string]interface{}); ok { + if raw, ok := msg["raw"].(string); ok && strings.TrimSpace(raw) != "" { + return strings.TrimSpace(raw) + } + } + if draft, ok := data["draft"].(map[string]interface{}); ok { + return extractRawEML(draft) + } + return "" +} diff --git a/shortcuts/mail/draft/testdata/alternative_append_text.golden.eml b/shortcuts/mail/draft/testdata/alternative_append_text.golden.eml new file mode 100644 index 00000000..9753586c --- /dev/null +++ b/shortcuts/mail/draft/testdata/alternative_append_text.golden.eml @@ -0,0 +1,18 @@ +Subject: Alternative +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary=alt + +--alt +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +hello +append +--alt +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +

hello

+--alt-- diff --git a/shortcuts/mail/draft/testdata/alternative_draft.eml b/shortcuts/mail/draft/testdata/alternative_draft.eml new file mode 100644 index 00000000..fbb7ffc6 --- /dev/null +++ b/shortcuts/mail/draft/testdata/alternative_draft.eml @@ -0,0 +1,17 @@ +Subject: Alternative +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary=alt + +--alt +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +hello +--alt +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +

hello

+--alt-- diff --git a/shortcuts/mail/draft/testdata/alternative_set_body.golden.eml b/shortcuts/mail/draft/testdata/alternative_set_body.golden.eml new file mode 100644 index 00000000..f0972cbb --- /dev/null +++ b/shortcuts/mail/draft/testdata/alternative_set_body.golden.eml @@ -0,0 +1,17 @@ +Subject: Alternative +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary=alt + +--alt +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +updated body +--alt +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +
updated body
+--alt-- diff --git a/shortcuts/mail/draft/testdata/calendar_draft.eml b/shortcuts/mail/draft/testdata/calendar_draft.eml new file mode 100644 index 00000000..1e25b456 --- /dev/null +++ b/shortcuts/mail/draft/testdata/calendar_draft.eml @@ -0,0 +1,23 @@ +Subject: Meeting invite +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/alternative; boundary=alt + +--alt +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +Team sync invite +--alt +Content-Type: text/calendar; charset=UTF-8; method=REQUEST +Content-Transfer-Encoding: 7bit + +BEGIN:VCALENDAR +VERSION:2.0 +METHOD:REQUEST +BEGIN:VEVENT +SUMMARY:Team Sync +END:VEVENT +END:VCALENDAR +--alt-- diff --git a/shortcuts/mail/draft/testdata/custom_header_draft.eml b/shortcuts/mail/draft/testdata/custom_header_draft.eml new file mode 100644 index 00000000..8c561315 --- /dev/null +++ b/shortcuts/mail/draft/testdata/custom_header_draft.eml @@ -0,0 +1,10 @@ +Subject: Custom +From: Alice +To: Bob +X-Trace-ID: abc123 +X-Feature-Flag: on +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +hello diff --git a/shortcuts/mail/draft/testdata/custom_header_set_subject.golden.eml b/shortcuts/mail/draft/testdata/custom_header_set_subject.golden.eml new file mode 100644 index 00000000..6c6495b2 --- /dev/null +++ b/shortcuts/mail/draft/testdata/custom_header_set_subject.golden.eml @@ -0,0 +1,10 @@ +Subject: Updated custom +From: Alice +To: Bob +X-Trace-ID: abc123 +X-Feature-Flag: on +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +hello diff --git a/shortcuts/mail/draft/testdata/dirty_multipart_preamble.eml b/shortcuts/mail/draft/testdata/dirty_multipart_preamble.eml new file mode 100644 index 00000000..b1a732cf --- /dev/null +++ b/shortcuts/mail/draft/testdata/dirty_multipart_preamble.eml @@ -0,0 +1,24 @@ +Subject: Dirty multipart +From: Alice +To: Bob +X-Trace: first + second +MIME-Version: 1.0 +cOnTeNt-TyPe: multipart/mixed; boundary=mix + +This is a preamble line. +Still preamble. +--mix +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +hello +--mix +Content-Type: application/octet-stream; name=data.bin +Content-Disposition: attachment; filename=data.bin +Content-Transfer-Encoding: base64 + +YQ== +--mix-- +This is an epilogue line. +Trailing dirty text. diff --git a/shortcuts/mail/draft/testdata/forward_draft.eml b/shortcuts/mail/draft/testdata/forward_draft.eml new file mode 100644 index 00000000..c4b13063 --- /dev/null +++ b/shortcuts/mail/draft/testdata/forward_draft.eml @@ -0,0 +1,24 @@ +Subject: Fwd: report +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary=mix + +--mix +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +hello +--mix +Content-Type: application/pdf; name=one.pdf +Content-Disposition: attachment; filename=one.pdf +Content-Transfer-Encoding: base64 + +b25l +--mix +Content-Type: application/pdf; name=two.pdf +Content-Disposition: attachment; filename=two.pdf +Content-Transfer-Encoding: base64 + +dHdv +--mix-- diff --git a/shortcuts/mail/draft/testdata/forward_remove_attachment.golden.eml b/shortcuts/mail/draft/testdata/forward_remove_attachment.golden.eml new file mode 100644 index 00000000..bf866827 --- /dev/null +++ b/shortcuts/mail/draft/testdata/forward_remove_attachment.golden.eml @@ -0,0 +1,18 @@ +Subject: Fwd: report +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary=mix + +--mix +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +hello +--mix +Content-Type: application/pdf; name=one.pdf +Content-Disposition: attachment; filename=one.pdf +Content-Transfer-Encoding: base64 + +b25l +--mix-- diff --git a/shortcuts/mail/draft/testdata/html_inline_draft.eml b/shortcuts/mail/draft/testdata/html_inline_draft.eml new file mode 100644 index 00000000..6e80fe2a --- /dev/null +++ b/shortcuts/mail/draft/testdata/html_inline_draft.eml @@ -0,0 +1,19 @@ +Subject: Inline draft +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/related; boundary=rel + +--rel +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +
hello
+--rel +Content-Type: image/png; name=logo.png +Content-Disposition: inline; filename=logo.png +Content-ID: +Content-Transfer-Encoding: base64 + +aGVsbG8= +--rel-- diff --git a/shortcuts/mail/draft/testdata/html_inline_remove.golden.eml b/shortcuts/mail/draft/testdata/html_inline_remove.golden.eml new file mode 100644 index 00000000..6cb5d793 --- /dev/null +++ b/shortcuts/mail/draft/testdata/html_inline_remove.golden.eml @@ -0,0 +1,12 @@ +Subject: Inline draft +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/related; boundary=rel + +--rel +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +
updated without image
+--rel-- diff --git a/shortcuts/mail/draft/testdata/html_inline_replace.golden.eml b/shortcuts/mail/draft/testdata/html_inline_replace.golden.eml new file mode 100644 index 00000000..9442b69e --- /dev/null +++ b/shortcuts/mail/draft/testdata/html_inline_replace.golden.eml @@ -0,0 +1,19 @@ +Subject: Inline draft +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/related; boundary=rel + +--rel +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +
updated
+--rel +Content-Type: image/png; name=logo.png +Content-Disposition: inline; filename=logo.png +Content-ID: +Content-Transfer-Encoding: base64 + +aGVsbG8= +--rel-- diff --git a/shortcuts/mail/draft/testdata/html_inline_replace_binary.golden.eml b/shortcuts/mail/draft/testdata/html_inline_replace_binary.golden.eml new file mode 100644 index 00000000..d89f6aac --- /dev/null +++ b/shortcuts/mail/draft/testdata/html_inline_replace_binary.golden.eml @@ -0,0 +1,19 @@ +Subject: Inline draft +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/related; boundary=rel + +--rel +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +
hello
+--rel +Content-Type: image/png; name=logo.png +Content-Disposition: inline; filename=logo.png +Content-ID: +Content-Transfer-Encoding: base64 + +iVBORw0KGgo= +--rel-- diff --git a/shortcuts/mail/draft/testdata/message_rfc822_draft.eml b/shortcuts/mail/draft/testdata/message_rfc822_draft.eml new file mode 100644 index 00000000..028e635e --- /dev/null +++ b/shortcuts/mail/draft/testdata/message_rfc822_draft.eml @@ -0,0 +1,24 @@ +Subject: Forwarded thread +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary=mix + +--mix +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +See forwarded message +--mix +Content-Type: message/rfc822; name=forwarded.eml +Content-Disposition: attachment; filename=forwarded.eml +Content-Transfer-Encoding: 7bit + +Subject: Inner message +From: Carol +To: Bob +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +inner body +--mix-- diff --git a/shortcuts/mail/draft/testdata/multipart_signed_draft.eml b/shortcuts/mail/draft/testdata/multipart_signed_draft.eml new file mode 100644 index 00000000..6ac4d476 --- /dev/null +++ b/shortcuts/mail/draft/testdata/multipart_signed_draft.eml @@ -0,0 +1,18 @@ +Subject: Signed draft +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: multipart/signed; boundary=sig; protocol="application/pkcs7-signature"; micalg=sha-256 + +--sig +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +signed body +--sig +Content-Type: application/pkcs7-signature; name=smime.p7s +Content-Disposition: attachment; filename=smime.p7s +Content-Transfer-Encoding: base64 + +c2lnbmF0dXJl +--sig-- diff --git a/shortcuts/mail/draft/testdata/reply_draft.eml b/shortcuts/mail/draft/testdata/reply_draft.eml new file mode 100644 index 00000000..c9ee86de --- /dev/null +++ b/shortcuts/mail/draft/testdata/reply_draft.eml @@ -0,0 +1,11 @@ +Subject: Original reply +From: Alice +To: Bob +Message-ID: +In-Reply-To: +References: +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +hello diff --git a/shortcuts/mail/draft/testdata/reply_draft_subject.golden.eml b/shortcuts/mail/draft/testdata/reply_draft_subject.golden.eml new file mode 100644 index 00000000..6d3d1afe --- /dev/null +++ b/shortcuts/mail/draft/testdata/reply_draft_subject.golden.eml @@ -0,0 +1,11 @@ +Subject: Updated reply +From: Alice +To: Bob +Message-ID: +In-Reply-To: +References: +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +hello diff --git a/shortcuts/mail/draft/testdata/reply_draft_with_inline_attachment.eml b/shortcuts/mail/draft/testdata/reply_draft_with_inline_attachment.eml new file mode 100644 index 00000000..ed94d6f9 --- /dev/null +++ b/shortcuts/mail/draft/testdata/reply_draft_with_inline_attachment.eml @@ -0,0 +1,28 @@ +Subject: Original reply +From: Alice +To: Bob +Message-ID: +In-Reply-To: +References: +MIME-Version: 1.0 +Content-Type: multipart/related; boundary=rel + +--rel +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: 7bit + +
hello
+--rel +Content-Type: image/png; name=logo.png +Content-Disposition: inline; filename=logo.png +Content-ID: +Content-Transfer-Encoding: base64 + +aGVsbG8= +--rel +Content-Type: application/pdf; name=report.pdf +Content-Disposition: attachment; filename=report.pdf +Content-Transfer-Encoding: base64 + +cmVwb3J0 +--rel-- diff --git a/shortcuts/mail/emlbuilder/builder.go b/shortcuts/mail/emlbuilder/builder.go new file mode 100644 index 00000000..61f416d0 --- /dev/null +++ b/shortcuts/mail/emlbuilder/builder.go @@ -0,0 +1,951 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package emlbuilder provides a Lark-API-compatible RFC 2822 EML message builder. +// +// It is designed for use with the Lark mail drafts API +// (POST /open-apis/mail/v1/user_mailboxes/me/drafts), which requires the +// complete EML to be base64url-encoded and placed in the "raw" request field. +// After creating a draft, send it via POST .../drafts/{draft_id}/send. +// +// Key differences from standard MIME libraries: +// - Line endings are LF (\n), not CRLF — Lark API requires this. +// - Content-Type parameters are never folded onto a new line — Lark's MIME +// parser does not handle header folding correctly. +// - Non-ASCII body content is encoded as base64 (StdEncoding) — 7bit and 8bit +// are rejected by Lark for non-ASCII content. +// - BuildBase64URL() produces the base64url (URLEncoding) output that goes +// directly into the API's "raw" field. +// +// MIME structure produced by Build(): +// +// multipart/mixed ← only when attachments exist +// └─ multipart/related ← only when CID inline/other parts exist +// └─ multipart/alternative ← only when multiple body types coexist +// ├─ text/plain +// ├─ text/html +// └─ text/calendar +// └─ inline/other parts (CID) +// └─ attachments +// +// Usage: +// +// raw, err := emlbuilder.New(). +// From("", "alice@example.com"). +// To("", "bob@example.com"). +// Subject("Hello"). +// TextBody([]byte("Hi Bob")). +// HTMLBody([]byte("

Hi Bob

")). +// AddInline(imgBytes, "image/png", "logo.png", "logo"). +// BuildBase64URL() +package emlbuilder + +import ( + "bytes" + "encoding/base64" + "fmt" + "math/rand" + "mime" + "net/mail" + "os" + "path/filepath" + "strings" + "time" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/mail/filecheck" +) + +// MaxEMLSize is the maximum allowed raw EML size in bytes. +const MaxEMLSize = 25 * 1024 * 1024 // 25 MB + +// readFile reads the named file and returns its contents. +func readFile(path string) ([]byte, error) { + safePath, err := validate.SafeInputPath(path) + if err != nil { + return nil, fmt.Errorf("attachment %q: %w", path, err) + } + return os.ReadFile(safePath) +} + +// Builder constructs a Lark-compatible RFC 2822 EML message. +// All setter methods return a copy of the Builder (immutable/fluent style), +// so a base builder can be reused across multiple goroutines safely. +type Builder struct { + from mail.Address + to []mail.Address + cc []mail.Address + bcc []mail.Address + replyTo []mail.Address + subject string + date time.Time + messageID string + inReplyTo string // raw value, without angle brackets + references string // space-separated list of message IDs, with angle brackets + lmsReplyToMessageID string // Lark internal message_id of the original message + textBody []byte + htmlBody []byte + calendarBody []byte + attachments []attachment + inlines []inline + extraHeaders [][2]string // ordered list of [name, value] pairs + allowNoRecipients bool // when true, Build() skips the recipient check (for drafts) + err error +} + +type attachment struct { + content []byte + contentType string + fileName string +} + +// inline represents a CID-referenced embedded MIME part (inline image or other resource). +type inline struct { + content []byte + contentType string + fileName string + contentID string // without angle brackets + isOtherPart bool // true = no Content-Disposition (AddOtherPart); false = Content-Disposition: inline +} + +// New returns an empty Builder. +func New() Builder { + return Builder{} +} + +// validateHeaderValue rejects strings that contain characters unsafe in MIME +// header values: C0 control chars (except \t for folded headers), DEL (0x7F), +// and dangerous Unicode (Bidi overrides, zero-width chars) that enable +// visual-spoofing attacks. +func validateHeaderValue(v string) error { + for _, r := range v { + if r != '\t' && (r < 0x20 || r == 0x7f) { + return fmt.Errorf("emlbuilder: header value contains control character: %q", v) + } + if isHeaderDangerousUnicode(r) { + return fmt.Errorf("emlbuilder: header value contains dangerous Unicode character: %q", v) + } + } + return nil +} + +// isHeaderDangerousUnicode identifies Unicode code points used for visual +// spoofing: Bidi overrides that reverse display order, and zero-width characters +// that hide content. These must not appear in email header values. +func isHeaderDangerousUnicode(r rune) bool { + switch { + case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner + return true + case r == 0xFEFF: // BOM / zero-width no-break space + return true + case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO + return true + case r >= 0x2028 && r <= 0x2029: // line/paragraph separator + return true + case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI + return true + } + return false +} + +// validateHeaderName rejects any string that contains ':', CR (\r), LF (\n), +// or non-printable ASCII characters, as required by RFC 5322 field-name syntax. +func validateHeaderName(n string) error { + if strings.ContainsAny(n, ":\r\n") { + return fmt.Errorf("emlbuilder: header name contains ':', CR, or LF: %q", n) + } + for _, r := range n { + if r < 0x21 || r > 0x7e { + return fmt.Errorf("emlbuilder: header name contains non-printable character: %q", n) + } + } + return nil +} + +// validateDisplayName rejects display names containing CR or LF, which could +// escape the quoted-string encoding used by mail.Address.String() and inject headers. +func validateDisplayName(name string) error { + if strings.ContainsAny(name, "\r\n") { + return fmt.Errorf("emlbuilder: display name contains CR or LF: %q", name) + } + return nil +} + +// validateCID rejects content IDs containing ASCII control characters (0x00–0x1F, 0x7F). +// RFC 2045 Content-ID has the same syntax as Message-ID; control characters are never valid. +func validateCID(cid string) error { + for _, r := range cid { + if r < 0x20 || r == 0x7f { + return fmt.Errorf("emlbuilder: content ID contains control character: %q", cid) + } + } + return nil +} + +// From sets the From header. name may be empty. +func (b Builder) From(name, addr string) Builder { + if b.err != nil { + return b + } + if err := validateDisplayName(name); err != nil { + b.err = err + return b + } + b.from = mail.Address{Name: name, Address: addr} + return b +} + +// To appends an address to the To header. name may be empty. +func (b Builder) To(name, addr string) Builder { + if addr == "" { + return b + } + if b.err != nil { + return b + } + if err := validateDisplayName(name); err != nil { + b.err = err + return b + } + cp := b.copySlices() + cp.to = append(cp.to, mail.Address{Name: name, Address: addr}) + return cp +} + +// ToAddrs sets the To header to the given address list. +func (b Builder) ToAddrs(addrs []mail.Address) Builder { + b.to = addrs + return b +} + +// CC appends an address to the Cc header. name may be empty. +func (b Builder) CC(name, addr string) Builder { + if addr == "" { + return b + } + if b.err != nil { + return b + } + if err := validateDisplayName(name); err != nil { + b.err = err + return b + } + cp := b.copySlices() + cp.cc = append(cp.cc, mail.Address{Name: name, Address: addr}) + return cp +} + +// CCAddrs sets the Cc header to the given address list. +func (b Builder) CCAddrs(addrs []mail.Address) Builder { + b.cc = addrs + return b +} + +// BCC appends an address to the Bcc list. +// Bcc addresses are included in AllRecipients() but not written to the EML headers. +func (b Builder) BCC(name, addr string) Builder { + if addr == "" { + return b + } + if b.err != nil { + return b + } + if err := validateDisplayName(name); err != nil { + b.err = err + return b + } + cp := b.copySlices() + cp.bcc = append(cp.bcc, mail.Address{Name: name, Address: addr}) + return cp +} + +// BCCAddrs sets the Bcc list to the given address list. +func (b Builder) BCCAddrs(addrs []mail.Address) Builder { + b.bcc = addrs + return b +} + +// ReplyTo appends an address to the Reply-To header. name may be empty. +func (b Builder) ReplyTo(name, addr string) Builder { + if addr == "" { + return b + } + if b.err != nil { + return b + } + if err := validateDisplayName(name); err != nil { + b.err = err + return b + } + cp := b.copySlices() + cp.replyTo = append(cp.replyTo, mail.Address{Name: name, Address: addr}) + return cp +} + +// Subject sets the Subject header. +// Non-ASCII characters are automatically RFC 2047 B-encoded. +// Returns an error builder if subject contains CR or LF. +func (b Builder) Subject(subject string) Builder { + if b.err != nil { + return b + } + if err := validateHeaderValue(subject); err != nil { + b.err = err + return b + } + b.subject = subject + return b +} + +// Date sets the Date header. If not set, Build() uses time.Now(). +func (b Builder) Date(date time.Time) Builder { + b.date = date + return b +} + +// MessageID sets the Message-ID header value (without angle brackets). +// If not set, Build() generates a unique ID. +// Returns an error builder if id contains CR or LF. +func (b Builder) MessageID(id string) Builder { + if b.err != nil { + return b + } + if err := validateHeaderValue(id); err != nil { + b.err = err + return b + } + b.messageID = id + return b +} + +// InReplyTo sets the In-Reply-To header (the smtp_message_id of the original mail, +// without angle brackets). Used for reply threading. +// Returns an error builder if id contains CR or LF. +func (b Builder) InReplyTo(id string) Builder { + if b.err != nil { + return b + } + if err := validateHeaderValue(id); err != nil { + b.err = err + return b + } + b.inReplyTo = id + return b +} + +// LMSReplyToMessageID sets the Lark internal message_id of the original message. +// Written as X-LMS-Reply-To-Message-Id when In-Reply-To is also set. +// Returns an error builder if id contains CR or LF. +func (b Builder) LMSReplyToMessageID(id string) Builder { + if b.err != nil { + return b + } + if err := validateHeaderValue(id); err != nil { + b.err = err + return b + } + b.lmsReplyToMessageID = id + return b +} + +// References sets the References header value verbatim. +// Typically a space-separated list of message IDs including angle brackets, +// e.g. " ". +// Returns an error builder if refs contains CR or LF. +func (b Builder) References(refs string) Builder { + if b.err != nil { + return b + } + if err := validateHeaderValue(refs); err != nil { + b.err = err + return b + } + b.references = refs + return b +} + +// TextBody sets the text/plain body. +func (b Builder) TextBody(body []byte) Builder { + b.textBody = body + return b +} + +// HTMLBody sets the text/html body. +func (b Builder) HTMLBody(body []byte) Builder { + b.htmlBody = body + return b +} + +// CalendarBody sets the text/calendar body (e.g. for meeting invitations). +// May be combined with TextBody and/or HTMLBody; the resulting parts are wrapped +// in multipart/alternative. +func (b Builder) CalendarBody(body []byte) Builder { + b.calendarBody = body + return b +} + +// AddAttachment appends a file attachment. +// contentType should be a valid MIME type (e.g. "application/pdf"). +// If contentType is empty, "application/octet-stream" is used. +// Returns an error builder if contentType or fileName contains CR or LF. +func (b Builder) AddAttachment(content []byte, contentType, fileName string) Builder { + if b.err != nil { + return b + } + if err := validateHeaderValue(fileName); err != nil { + b.err = err + return b + } + if contentType == "" { + contentType = "application/octet-stream" + } + if err := validateHeaderValue(contentType); err != nil { + b.err = err + return b + } + cp := b.copySlices() + cp.attachments = append(cp.attachments, attachment{ + content: content, + contentType: contentType, + fileName: fileName, + }) + return cp +} + +// AddFileAttachment reads a file from disk and appends it as an attachment. +// The backend canonicalizes regular attachments to application/octet-stream on +// save/readback, so the builder aligns with that behavior instead of inferring +// a richer MIME type from the local file extension. If reading the file fails, +// the error is stored and returned by Build(). +func (b Builder) AddFileAttachment(path string) Builder { + if b.err != nil { + return b + } + if err := filecheck.CheckBlockedExtension(filepath.Base(path)); err != nil { + b.err = err + return b + } + content, err := readFile(path) + if err != nil { + b.err = err + return b + } + name := filepath.Base(path) + return b.AddAttachment(content, "application/octet-stream", name) +} + +// AddInline appends a CID-referenced inline part (e.g. an embedded image). +// The part is written with Content-Disposition: inline, causing most mail clients +// to render it inline rather than as a download. +// contentID is a unique identifier without angle brackets; it matches the "cid:" +// reference in the HTML body (e.g. contentID="logo.png" matches src="cid:logo.png"). +// When inline parts are present, the message body is automatically wrapped in +// multipart/related. +// Returns an error builder if contentType or fileName contains CR or LF, or if +// contentID contains any ASCII control character. +func (b Builder) AddInline(content []byte, contentType, fileName, contentID string) Builder { + if b.err != nil { + return b + } + if contentType == "" { + contentType = "application/octet-stream" + } + if err := validateHeaderValue(contentType); err != nil { + b.err = err + return b + } + if err := validateHeaderValue(fileName); err != nil { + b.err = err + return b + } + if err := validateCID(contentID); err != nil { + b.err = err + return b + } + cp := b.copySlices() + cp.inlines = append(cp.inlines, inline{ + content: content, + contentType: contentType, + fileName: fileName, + contentID: contentID, + isOtherPart: false, + }) + return cp +} + +// AddFileInline reads a file from disk and appends it as a CID inline part. +// The content type is inferred from the file extension. +// If reading the file fails, the error is stored and returned by Build(). +func (b Builder) AddFileInline(path, contentID string) Builder { + if b.err != nil { + return b + } + content, err := readFile(path) + if err != nil { + b.err = err + return b + } + name := filepath.Base(path) + ct, err := filecheck.CheckInlineImageFormat(name, content) + if err != nil { + b.err = err + return b + } + return b.AddInline(content, ct, name, contentID) +} + +// AddOtherPart appends a CID-referenced embedded part without Content-Disposition. +// Unlike AddInline, this part carries no Content-Disposition header, which is +// appropriate for resources referenced via "cid:" that should not appear as inline +// attachments in the client UI (e.g. calendar objects or custom data blobs). +// When other parts are present, the message body is automatically wrapped in +// multipart/related. +// Returns an error builder if contentType or fileName contains CR or LF, or if +// contentID contains any ASCII control character. +func (b Builder) AddOtherPart(content []byte, contentType, fileName, contentID string) Builder { + if b.err != nil { + return b + } + if contentType == "" { + contentType = "application/octet-stream" + } + if err := validateHeaderValue(contentType); err != nil { + b.err = err + return b + } + if err := validateHeaderValue(fileName); err != nil { + b.err = err + return b + } + if err := validateCID(contentID); err != nil { + b.err = err + return b + } + cp := b.copySlices() + cp.inlines = append(cp.inlines, inline{ + content: content, + contentType: contentType, + fileName: fileName, + contentID: contentID, + isOtherPart: true, + }) + return cp +} + +// AddFileOtherPart reads a file from disk and appends it as a CID other-part +// (no Content-Disposition header). See AddOtherPart for details. +// If reading the file fails, the error is stored and returned by Build(). +func (b Builder) AddFileOtherPart(path, contentID string) Builder { + if b.err != nil { + return b + } + content, err := readFile(path) + if err != nil { + b.err = err + return b + } + name := filepath.Base(path) + ct := mime.TypeByExtension(filepath.Ext(name)) + if ct == "" { + ct = "application/octet-stream" + } + return b.AddOtherPart(content, ct, name, contentID) +} + +// AllowNoRecipients tells Build() to skip the recipient-required check. +// Use this for draft creation, where saving without recipients is valid. +func (b Builder) AllowNoRecipients() Builder { + b.allowNoRecipients = true + return b +} + +// Header appends an extra header to the message. +// Multiple calls with the same name result in multiple header lines. +// Returns an error builder if name or value contains CR, LF, or (for names) ':'. +func (b Builder) Header(name, value string) Builder { + if b.err != nil { + return b + } + if err := validateHeaderName(name); err != nil { + b.err = err + return b + } + if err := validateHeaderValue(value); err != nil { + b.err = err + return b + } + cp := b.copySlices() + cp.extraHeaders = append(cp.extraHeaders, [2]string{name, value}) + return cp +} + +// Error returns any stored error (e.g. from AddFileAttachment), or nil. +func (b Builder) Error() error { + return b.err +} + +// AllRecipients returns all recipient addresses (To + CC + BCC). +// Useful for SMTP envelope construction. +func (b Builder) AllRecipients() []string { + out := make([]string, 0, len(b.to)+len(b.cc)+len(b.bcc)) + for _, a := range b.to { + out = append(out, a.Address) + } + for _, a := range b.cc { + out = append(out, a.Address) + } + for _, a := range b.bcc { + out = append(out, a.Address) + } + return out +} + +// Build validates the builder and returns the raw EML bytes. +// +// Constraints (Lark API requirements): +// - From is mandatory. +// - At least one of To/CC/BCC must be set. +// - Line endings are LF (\n), not CRLF. +// - Content-Type parameters are written on a single line (no header folding). +// - Non-ASCII body content is base64 (StdEncoding) encoded. +func (b Builder) Build() ([]byte, error) { + if b.err != nil { + return nil, b.err + } + if b.from.Address == "" { + return nil, fmt.Errorf("emlbuilder: From address is required") + } + if !b.allowNoRecipients && len(b.to)+len(b.cc)+len(b.bcc) == 0 { + return nil, fmt.Errorf("emlbuilder: at least one recipient (To/CC/BCC) is required") + } + + date := b.date + if date.IsZero() { + date = time.Now() + } + + msgID := b.messageID + if msgID == "" { + msgID = fmt.Sprintf("%d.%d@larksuite-cli", date.UnixNano(), rand.Int63()) + } + + var buf bytes.Buffer + + // ── Top-level headers ────────────────────────────────────────────────────── + // Order follows common convention; Lark API does not require a specific order. + writeHeader(&buf, "Subject", encodeHeaderValue(b.subject)) + writeHeader(&buf, "From", b.from.String()) + writeHeader(&buf, "MIME-Version", "1.0") + writeHeader(&buf, "Date", date.Format(time.RFC1123Z)) + writeHeader(&buf, "Message-ID", "<"+msgID+">") + + if len(b.to) > 0 { + writeHeader(&buf, "To", joinAddresses(b.to)) + } + if len(b.cc) > 0 { + writeHeader(&buf, "Cc", joinAddresses(b.cc)) + } + if len(b.bcc) > 0 { + writeHeader(&buf, "Bcc", joinAddresses(b.bcc)) + } + if len(b.replyTo) > 0 { + writeHeader(&buf, "Reply-To", joinAddresses(b.replyTo)) + } + if b.inReplyTo != "" { + writeHeader(&buf, "In-Reply-To", "<"+b.inReplyTo+">") + if b.lmsReplyToMessageID != "" { + writeHeader(&buf, "X-LMS-Reply-To-Message-Id", b.lmsReplyToMessageID) + } + } + if b.references != "" { + writeHeader(&buf, "References", b.references) + } + for _, kv := range b.extraHeaders { + writeHeader(&buf, kv[0], kv[1]) + } + + // ── Body ─────────────────────────────────────────────────────────────────── + // Full MIME hierarchy (outer layers only present when needed): + // multipart/mixed → multipart/related → multipart/alternative → body parts + if len(b.attachments) > 0 { + outerB := newBoundary() + writeHeader(&buf, "Content-Type", "multipart/mixed; boundary="+outerB) + buf.WriteByte('\n') + + fmt.Fprintf(&buf, "--%s\n", outerB) + writePrimaryBody(&buf, b) + + for _, att := range b.attachments { + fmt.Fprintf(&buf, "--%s\n", outerB) + writeAttachmentPart(&buf, att) + } + fmt.Fprintf(&buf, "--%s--\n", outerB) + } else { + writePrimaryBody(&buf, b) + } + + raw := buf.Bytes() + if len(raw) > MaxEMLSize { + return nil, fmt.Errorf("emlbuilder: EML size %.1f MB exceeds the %.0f MB limit", + float64(len(raw))/1024/1024, float64(MaxEMLSize)/1024/1024) + } + return raw, nil +} + +// BuildBase64URL returns the EML encoded as base64url (RFC 4648). +// This is the value to place in the Lark API "raw" field. +func (b Builder) BuildBase64URL() (string, error) { + raw, err := b.Build() + if err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(raw), nil +} + +// ── internal helpers ────────────────────────────────────────────────────────── + +// copySlices returns a shallow copy of b with independent slice headers, +// so append operations in setter methods do not mutate the original. +func (b Builder) copySlices() Builder { + cp := b + cp.to = append([]mail.Address{}, b.to...) + cp.cc = append([]mail.Address{}, b.cc...) + cp.bcc = append([]mail.Address{}, b.bcc...) + cp.replyTo = append([]mail.Address{}, b.replyTo...) + cp.attachments = append([]attachment{}, b.attachments...) + cp.inlines = append([]inline{}, b.inlines...) + cp.extraHeaders = append([][2]string{}, b.extraHeaders...) + return cp +} + +// writePrimaryBody writes the body block of the message (text + inline parts, +// but not attachments). If inline/other parts are present, the body is wrapped +// in multipart/related. +// +// This function writes starting from a Content-Type header, which is either a +// top-level message header (when no attachments) or a sub-part header (inside +// multipart/mixed after a boundary line). +func writePrimaryBody(buf *bytes.Buffer, b Builder) { + if len(b.inlines) > 0 { + relatedB := newBoundary() + writeHeader(buf, "Content-Type", "multipart/related; boundary="+relatedB) + buf.WriteByte('\n') + + fmt.Fprintf(buf, "--%s\n", relatedB) + writeAlternativeOrSingleBody(buf, b) + + for _, il := range b.inlines { + fmt.Fprintf(buf, "--%s\n", relatedB) + writeInlinePart(buf, il) + } + fmt.Fprintf(buf, "--%s--\n", relatedB) + } else { + writeAlternativeOrSingleBody(buf, b) + } +} + +// writeAlternativeOrSingleBody writes the text body block. +// If multiple body types (text/plain, text/html, text/calendar) are present, +// they are wrapped in multipart/alternative. Otherwise a single part is written. +func writeAlternativeOrSingleBody(buf *bytes.Buffer, b Builder) { + hasText := len(b.textBody) > 0 + hasHTML := len(b.htmlBody) > 0 + hasCal := len(b.calendarBody) > 0 + + bodyCount := 0 + if hasText { + bodyCount++ + } + if hasHTML { + bodyCount++ + } + if hasCal { + bodyCount++ + } + + switch { + case bodyCount > 1: + boundary := newBoundary() + writeHeader(buf, "Content-Type", "multipart/alternative; boundary="+boundary) + buf.WriteByte('\n') + if hasText { + writeBodyPart(buf, boundary, "text/plain", b.textBody) + } + if hasHTML { + writeBodyPart(buf, boundary, "text/html", b.htmlBody) + } + if hasCal { + writeBodyPart(buf, boundary, "text/calendar", b.calendarBody) + } + fmt.Fprintf(buf, "--%s--\n", boundary) + case hasHTML: + writeSingleBodyPartHeaders(buf, "text/html", b.htmlBody) + case hasCal: + writeSingleBodyPartHeaders(buf, "text/calendar", b.calendarBody) + default: + // text/plain (also handles empty body) + writeSingleBodyPartHeaders(buf, "text/plain", b.textBody) + } +} + +// writeInlinePart writes a CID-referenced inline or other-part MIME part. +// The part body is always base64 (StdEncoding), written in 76-character lines. +func writeInlinePart(buf *bytes.Buffer, il inline) { + rawCID := strings.TrimSpace(strings.TrimPrefix(strings.TrimSuffix(il.contentID, ">"), "<")) + cid := rawCID + if rawCID != "" { + cid = "<" + rawCID + ">" + } + encodedName := encodeHeaderValue(il.fileName) + fmt.Fprintf(buf, "Content-Type: %s; name=%q\n", il.contentType, encodedName) + writeHeader(buf, "Content-Id", cid) + writeHeader(buf, "Content-Transfer-Encoding", "base64") + if !il.isOtherPart { + fmt.Fprintf(buf, "Content-Disposition: inline; filename=%q\n", encodedName) + if rawCID != "" { + writeHeader(buf, "X-Attachment-Id", rawCID) + writeHeader(buf, "X-Image-Id", rawCID) + } + } + buf.WriteByte('\n') + + encoded := base64.StdEncoding.EncodeToString(il.content) + for len(encoded) > 76 { + buf.WriteString(encoded[:76]) + buf.WriteByte('\n') + encoded = encoded[76:] + } + if len(encoded) > 0 { + buf.WriteString(encoded) + buf.WriteByte('\n') + } + buf.WriteByte('\n') +} + +// writeHeader writes "Name: value\n". +// NOTE: no folding — Lark's MIME parser does not handle folded headers. +// CR and LF are stripped as a last-resort defence against header injection; +// callers (validateHeaderValue, validateCID) already reject them explicitly. +func writeHeader(buf *bytes.Buffer, name, value string) { + name = strings.NewReplacer("\r", "", "\n", "").Replace(name) + value = strings.NewReplacer("\r", "", "\n", "").Replace(value) + fmt.Fprintf(buf, "%s: %s\n", name, value) +} + +// encodeHeaderValue RFC 2047 B-encodes s if it contains non-ASCII characters. +func encodeHeaderValue(s string) string { + for _, r := range s { + if r > 127 { + return mime.BEncoding.Encode("utf-8", s) + } + } + return s +} + +// hasNonASCII returns true if b contains any byte > 127. +func hasNonASCII(b []byte) bool { + for _, c := range b { + if c > 127 { + return true + } + } + return false +} + +// selectCTE chooses the Content-Transfer-Encoding for a body: +// - "7bit" — pure ASCII content +// - "base64" — contains non-ASCII bytes (required by Lark API) +func selectCTE(body []byte) string { + if hasNonASCII(body) { + return "base64" + } + return "7bit" +} + +// encodeBodyContent encodes body according to the chosen CTE. +// For base64, it uses StdEncoding (MIME internal standard). +func encodeBodyContent(body []byte, cte string) string { + if cte == "base64" { + return base64.StdEncoding.EncodeToString(body) + } + return string(body) +} + +// writeFoldedBody writes the encoded part body with fixed-width line wrapping. +// RFC 2045 recommends 76 characters per encoded line; we apply the same width +// to all body parts for consistent MIME output. +func writeFoldedBody(buf *bytes.Buffer, encoded string, width int) { + if width <= 0 { + width = 76 + } + for _, line := range strings.Split(encoded, "\n") { + for len(line) > width { + buf.WriteString(line[:width]) + buf.WriteByte('\n') + line = line[width:] + } + buf.WriteString(line) + buf.WriteByte('\n') + } +} + +// writeBodyPart writes a MIME part within a multipart boundary: +// +// -- +// Content-Type: ; charset=UTF-8 +// Content-Transfer-Encoding: +// +// +// +func writeBodyPart(buf *bytes.Buffer, boundary, ct string, body []byte) { + fmt.Fprintf(buf, "--%s\n", boundary) + cte := selectCTE(body) + fmt.Fprintf(buf, "Content-Type: %s; charset=UTF-8\n", ct) + fmt.Fprintf(buf, "Content-Transfer-Encoding: %s\n\n", cte) + writeFoldedBody(buf, encodeBodyContent(body, cte), 76) +} + +// writeSingleBodyPartHeaders writes the Content-Type / CTE headers and body +// for a single-part (non-multipart) message. +// The blank line separating headers from body is included. +func writeSingleBodyPartHeaders(buf *bytes.Buffer, ct string, body []byte) { + cte := selectCTE(body) + fmt.Fprintf(buf, "Content-Type: %s; charset=UTF-8\n", ct) + fmt.Fprintf(buf, "Content-Transfer-Encoding: %s\n\n", cte) + writeFoldedBody(buf, encodeBodyContent(body, cte), 76) +} + +// writeAttachmentPart writes a MIME attachment part. +// Body is always base64 (StdEncoding), written in 76-character lines per RFC 2045. +func writeAttachmentPart(buf *bytes.Buffer, att attachment) { + encodedName := encodeHeaderValue(att.fileName) + fmt.Fprintf(buf, "Content-Type: %s; name=%q\n", att.contentType, encodedName) + fmt.Fprintf(buf, "Content-Disposition: attachment; filename=%q\n", encodedName) + fmt.Fprintf(buf, "Content-Transfer-Encoding: base64\n\n") + + encoded := base64.StdEncoding.EncodeToString(att.content) + for len(encoded) > 76 { + buf.WriteString(encoded[:76]) + buf.WriteByte('\n') + encoded = encoded[76:] + } + if len(encoded) > 0 { + buf.WriteString(encoded) + buf.WriteByte('\n') + } + buf.WriteByte('\n') +} + +// newBoundary generates a random MIME boundary string. +func newBoundary() string { + return fmt.Sprintf("lark-%016x", rand.Int63()) +} + +// joinAddresses formats a list of mail.Address as a comma-separated string. +func joinAddresses(addrs []mail.Address) string { + parts := make([]string, len(addrs)) + for i, a := range addrs { + parts[i] = a.String() + } + return strings.Join(parts, ", ") +} diff --git a/shortcuts/mail/emlbuilder/builder_test.go b/shortcuts/mail/emlbuilder/builder_test.go new file mode 100644 index 00000000..cef11475 --- /dev/null +++ b/shortcuts/mail/emlbuilder/builder_test.go @@ -0,0 +1,1083 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package emlbuilder + +import ( + "encoding/base64" + "net/mail" + "os" + "strings" + "testing" + "time" +) + +var fixedDate = time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC) + +// parseEML splits an EML string into a header block and body. +func splitHeaderBody(eml string) (headers, body string) { + idx := strings.Index(eml, "\n\n") + if idx == -1 { + return eml, "" + } + return eml[:idx], eml[idx+2:] +} + +func headerValue(eml, name string) string { + for _, line := range strings.Split(eml, "\n") { + if strings.HasPrefix(strings.ToLower(line), strings.ToLower(name)+":") { + return strings.TrimSpace(line[len(name)+1:]) + } + } + return "" +} + +// ── validation ──────────────────────────────────────────────────────────────── + +func TestBuild_MissingFrom(t *testing.T) { + _, err := New().To("", "bob@example.com").Subject("hi").Build() + if err == nil || !strings.Contains(err.Error(), "From") { + t.Fatalf("expected From error, got %v", err) + } +} + +func TestBuild_MissingRecipient(t *testing.T) { + _, err := New().From("", "alice@example.com").Subject("hi").Build() + if err == nil || !strings.Contains(err.Error(), "recipient") { + t.Fatalf("expected recipient error, got %v", err) + } +} + +// ── single text/plain ───────────────────────────────────────────────────────── + +func TestBuild_SingleTextPlain_ASCII(t *testing.T) { + raw, err := New(). + From("Alice", "alice@example.com"). + To("Bob", "bob@example.com"). + Subject("Hello"). + Date(fixedDate). + MessageID("test-id@lark-cli"). + TextBody([]byte("Hello world")). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + // must use LF, not CRLF + if strings.Contains(eml, "\r\n") { + t.Error("EML must use LF line endings, not CRLF") + } + + // required headers + for _, h := range []string{"Subject: Hello", "From:", "MIME-Version: 1.0", "Message-ID:"} { + if !strings.Contains(eml, h) { + t.Errorf("missing header %q in:\n%s", h, eml) + } + } + + // content type must not be folded (all params on one line) + for _, line := range strings.Split(eml, "\n") { + if strings.Contains(line, "Content-Type:") && strings.Contains(line, "boundary=") { + if !strings.Contains(line, "boundary=") { + t.Errorf("Content-Type with boundary must be on a single line: %q", line) + } + } + } + + // 7bit CTE for ASCII + if !strings.Contains(eml, "Content-Transfer-Encoding: 7bit") { + t.Errorf("expected 7bit CTE for ASCII body, got:\n%s", eml) + } + if !strings.Contains(eml, "Hello world") { + t.Error("body text missing") + } +} + +func TestBuild_SingleTextPlain_NonASCII(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("你好"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("你好世界")). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + // subject must be RFC 2047 encoded + subj := headerValue(eml, "Subject") + if subj == "你好" { + t.Error("non-ASCII subject must be RFC 2047 encoded") + } + if !strings.HasPrefix(subj, "=?utf-8?") && !strings.HasPrefix(subj, "=?UTF-8?") { + t.Errorf("unexpected subject encoding: %q", subj) + } + + // body must be base64 + if !strings.Contains(eml, "Content-Transfer-Encoding: base64") { + t.Errorf("expected base64 CTE for non-ASCII body:\n%s", eml) + } + + // body content must be valid base64 of the original text + headers, body := splitHeaderBody(eml) + _ = headers + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(body)) + if err != nil { + t.Fatalf("body is not valid base64: %v\nbody=%q", err, body) + } + if string(decoded) != "你好世界" { + t.Errorf("decoded body mismatch: got %q", decoded) + } +} + +// ── multipart/alternative ───────────────────────────────────────────────────── + +func TestBuild_MultipartAlternative(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("plain")). + HTMLBody([]byte("

html

")). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + if !strings.Contains(eml, "multipart/alternative") { + t.Error("expected multipart/alternative") + } + // boundary must be on the same line as Content-Type + for _, line := range strings.Split(eml, "\n") { + if strings.HasPrefix(line, "Content-Type: multipart/") { + if !strings.Contains(line, "boundary=") { + t.Errorf("Content-Type line missing boundary param: %q", line) + } + } + } + if !strings.Contains(eml, "text/plain") { + t.Error("missing text/plain part") + } + if !strings.Contains(eml, "text/html") { + t.Error("missing text/html part") + } +} + +// ── multipart/mixed (with attachments) ─────────────────────────────────────── + +func TestBuild_WithAttachment(t *testing.T) { + attContent := []byte("PDF content here") + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("with attachment"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("see attached")). + AddAttachment(attContent, "application/pdf", "doc.pdf"). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + if !strings.Contains(eml, "multipart/mixed") { + t.Error("expected multipart/mixed for message with attachments") + } + if !strings.Contains(eml, `Content-Disposition: attachment; filename="doc.pdf"`) { + t.Errorf("missing attachment disposition:\n%s", eml) + } + + // attachment body must be base64 of attContent + expectedB64 := base64.StdEncoding.EncodeToString(attContent) + if !strings.Contains(eml, expectedB64) { + t.Errorf("attachment base64 not found in EML:\n%s", eml) + } +} + +// ── reply threading headers ─────────────────────────────────────────────────── + +func TestBuild_ReplyHeaders(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("Re: hello"). + Date(fixedDate). + MessageID("reply@x"). + InReplyTo("original@smtp"). + References(""). + TextBody([]byte("my reply")). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + inReplyTo := headerValue(eml, "In-Reply-To") + if inReplyTo != "" { + t.Errorf("In-Reply-To: got %q, want ", inReplyTo) + } + refs := headerValue(eml, "References") + if refs != "" { + t.Errorf("References: got %q, want ", refs) + } +} + +func TestBuild_LMSReplyToMessageID(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("Re: hello"). + Date(fixedDate). + InReplyTo("original@smtp"). + LMSReplyToMessageID("740000000000000067"). + TextBody([]byte("my reply")). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + got := headerValue(eml, "X-LMS-Reply-To-Message-Id") + if got != "740000000000000067" { + t.Errorf("X-LMS-Reply-To-Message-Id: got %q, want 740000000000000067", got) + } +} + +func TestBuild_LMSReplyToMessageID_NotWrittenWithoutInReplyTo(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("hello"). + Date(fixedDate). + LMSReplyToMessageID("740000000000000067"). + TextBody([]byte("body")). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + got := headerValue(eml, "X-LMS-Reply-To-Message-Id") + if got != "" { + t.Errorf("X-LMS-Reply-To-Message-Id should be absent when In-Reply-To is not set, got %q", got) + } +} + +// ── CC / BCC ────────────────────────────────────────────────────────────────── + +func TestBuild_CCBCC(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + CC("", "charlie@example.com"). + BCC("", "dave@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("body")). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + if !strings.Contains(eml, "charlie@example.com") { + t.Errorf("missing Cc address:\n%s", eml) + } + if !strings.Contains(eml, "Cc:") { + t.Errorf("missing Cc header:\n%s", eml) + } + if !strings.Contains(eml, "Bcc:") { + t.Errorf("missing Bcc header:\n%s", eml) + } + if !strings.Contains(eml, "dave@example.com") { + t.Errorf("missing Bcc address:\n%s", eml) + } +} + +func TestAllRecipients(t *testing.T) { + b := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + CC("", "charlie@example.com"). + BCC("", "dave@example.com") + recips := b.AllRecipients() + if len(recips) != 3 { + t.Fatalf("expected 3 recipients, got %d: %v", len(recips), recips) + } +} + +// ── BuildBase64URL ──────────────────────────────────────────────────────────── + +func TestBuildBase64URL(t *testing.T) { + encoded, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("base64url test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hello")). + BuildBase64URL() + if err != nil { + t.Fatal(err) + } + + // must be valid base64url + decoded, err := base64.URLEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("BuildBase64URL produced invalid base64url: %v", err) + } + + // decoded must be valid EML + if !strings.Contains(string(decoded), "Subject: base64url test") { + t.Errorf("decoded EML missing expected content:\n%s", decoded) + } + + // must NOT contain standard base64 chars that differ from base64url + // ('+' → '-', '/' → '_') + if strings.ContainsAny(encoded, "+/") { + t.Error("BuildBase64URL must use base64url encoding (- and _ instead of + and /)") + } +} + +// ── immutability ────────────────────────────────────────────────────────────── + +func TestBuilder_Immutability(t *testing.T) { + base := New().From("", "alice@example.com").Subject("base") + b1 := base.To("", "bob@example.com") + b2 := base.To("", "charlie@example.com") + + if len(b1.to) != 1 || b1.to[0].Address != "bob@example.com" { + t.Errorf("b1 unexpected to: %v", b1.to) + } + if len(b2.to) != 1 || b2.to[0].Address != "charlie@example.com" { + t.Errorf("b2 unexpected to: %v", b2.to) + } + // base should have no To + if len(base.to) != 0 { + t.Errorf("base was mutated: to=%v", base.to) + } +} + +// ── ToAddrs / CCAddrs ───────────────────────────────────────────────────────── + +func TestBuild_ToAddrs(t *testing.T) { + addrs := []mail.Address{ + {Name: "Bob", Address: "bob@example.com"}, + {Name: "Carol", Address: "carol@example.com"}, + } + raw, err := New(). + From("", "alice@example.com"). + ToAddrs(addrs). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + if !strings.Contains(eml, "bob@example.com") || !strings.Contains(eml, "carol@example.com") { + t.Errorf("expected both recipients in EML:\n%s", eml) + } +} + +// ── CalendarBody ────────────────────────────────────────────────────────────── + +func TestBuild_CalendarBody_Single(t *testing.T) { + calData := []byte("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR") + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("Meeting"). + Date(fixedDate). + MessageID("test@x"). + CalendarBody(calData). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + if !strings.Contains(eml, "text/calendar") { + t.Errorf("expected text/calendar in EML:\n%s", eml) + } + if strings.Contains(eml, "multipart") { + t.Errorf("single calendar body should not produce multipart:\n%s", eml) + } +} + +func TestBuild_CalendarWithText(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("Meeting"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("You are invited.")). + CalendarBody([]byte("BEGIN:VCALENDAR\r\nEND:VCALENDAR")). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + if !strings.Contains(eml, "multipart/alternative") { + t.Errorf("expected multipart/alternative for text+calendar:\n%s", eml) + } + if !strings.Contains(eml, "text/plain") { + t.Errorf("missing text/plain part:\n%s", eml) + } + if !strings.Contains(eml, "text/calendar") { + t.Errorf("missing text/calendar part:\n%s", eml) + } +} + +// ── AddInline / multipart/related ──────────────────────────────────────────── + +func TestBuild_WithInline(t *testing.T) { + imgBytes := []byte("\x89PNG\r\n\x1a\n") // minimal PNG magic bytes + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("inline image"). + Date(fixedDate). + MessageID("test@x"). + HTMLBody([]byte(``)). + AddInline(imgBytes, "image/png", "logo.png", "logo"). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + if !strings.Contains(eml, "multipart/related") { + t.Errorf("expected multipart/related when inlines present:\n%s", eml) + } + if !strings.Contains(eml, "Content-Id: ") { + t.Errorf("missing Content-Id header:\n%s", eml) + } + if !strings.Contains(eml, "Content-Disposition: inline") { + t.Errorf("missing Content-Disposition: inline:\n%s", eml) + } + if !strings.Contains(eml, `Content-Disposition: inline; filename="logo.png"`) { + t.Errorf("missing quoted inline filename:\n%s", eml) + } + if !strings.Contains(eml, "X-Attachment-Id: logo") { + t.Errorf("missing X-Attachment-Id:\n%s", eml) + } + if !strings.Contains(eml, "X-Image-Id: logo") { + t.Errorf("missing X-Image-Id:\n%s", eml) + } + if !strings.Contains(eml, "image/png") { + t.Errorf("missing image/png Content-Type:\n%s", eml) + } +} + +func TestBuild_WithOtherPart(t *testing.T) { + calData := []byte("BEGIN:VCALENDAR\r\nEND:VCALENDAR") + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("other part"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("see embedded")). + AddOtherPart(calData, "text/calendar", "invite.ics", "cal001"). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + if !strings.Contains(eml, "multipart/related") { + t.Errorf("expected multipart/related for other parts:\n%s", eml) + } + if !strings.Contains(eml, "Content-Id: ") { + t.Errorf("missing Content-ID:\n%s", eml) + } + // AddOtherPart must NOT write Content-Disposition + if strings.Contains(eml, "Content-Disposition") { + t.Errorf("AddOtherPart must not include Content-Disposition:\n%s", eml) + } +} + +func TestBuild_FoldBodyLines_Base64(t *testing.T) { + body := strings.Repeat("你", 120) + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("fold html"). + Date(fixedDate). + MessageID("test@x"). + HTMLBody([]byte(body)). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + headers, bodyPart := splitHeaderBody(eml) + _ = headers + lines := strings.Split(strings.TrimSpace(bodyPart), "\n") + for i, line := range lines { + if len(line) > 76 { + t.Fatalf("base64 line %d too long: %d", i, len(line)) + } + } +} + +func TestBuild_FoldBodyLines_7bit(t *testing.T) { + body := strings.Repeat("A", 200) + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("fold text"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte(body)). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + headers, bodyPart := splitHeaderBody(eml) + _ = headers + lines := strings.Split(strings.TrimSpace(bodyPart), "\n") + for i, line := range lines { + if len(line) > 76 { + t.Fatalf("7bit line %d too long: %d", i, len(line)) + } + } +} + +func TestBuild_InlineAndAttachment(t *testing.T) { + imgBytes := []byte("fake-png") + pdfBytes := []byte("fake-pdf") + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("inline+attachment"). + Date(fixedDate). + MessageID("test@x"). + HTMLBody([]byte(``)). + AddInline(imgBytes, "image/png", "img.png", "img1"). + AddAttachment(pdfBytes, "application/pdf", "doc.pdf"). + Build() + if err != nil { + t.Fatal(err) + } + eml := string(raw) + + if !strings.Contains(eml, "multipart/mixed") { + t.Errorf("expected multipart/mixed (attachment present):\n%s", eml) + } + if !strings.Contains(eml, "multipart/related") { + t.Errorf("expected multipart/related (inline present):\n%s", eml) + } + if !strings.Contains(eml, "Content-Disposition: attachment") { + t.Errorf("missing attachment disposition:\n%s", eml) + } + if !strings.Contains(eml, "Content-Id: ") { + t.Errorf("missing inline Content-ID:\n%s", eml) + } +} + +// ContentID without angle brackets is normalised to form. +func TestBuild_InlineContentIDNormalisation(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("cid test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("body")). + AddInline([]byte("data"), "image/gif", "a.gif", "already-no-brackets"). + Build() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "Content-Id: ") { + t.Errorf("Content-ID should be wrapped in angle brackets:\n%s", raw) + } +} + +// ── extra Header ───────────────────────────────────────────────────────────── + +func TestBuild_ExtraHeader(t *testing.T) { + raw, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + Header("X-Custom", "my-value"). + Build() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "X-Custom: my-value") { + t.Errorf("extra header missing:\n%s", raw) + } +} + +// ── CRLF / header-injection guards ─────────────────────────────────────────── + +func TestSubjectCRLFRejected(t *testing.T) { + for _, inj := range []string{"legit\r\nBcc: evil@evil.com", "legit\nBcc: evil@evil.com", "legit\rBcc: evil@evil.com"} { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject(inj). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + Build() + if err == nil { + t.Errorf("Subject(%q): expected error, got nil", inj) + } + } +} + +func TestMessageIDCRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("bad\r\nX-Injected: hdr"). + TextBody([]byte("hi")). + Build() + if err == nil { + t.Error("MessageID with CRLF: expected error, got nil") + } +} + +func TestInReplyToCRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + InReplyTo("legit\r\nBcc: evil@evil.com"). + TextBody([]byte("hi")). + Build() + if err == nil { + t.Error("InReplyTo with CRLF: expected error, got nil") + } +} + +func TestReferencesCRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + References("legit@x\r\nBcc: evil@evil.com"). + TextBody([]byte("hi")). + Build() + if err == nil { + t.Error("References with CRLF: expected error, got nil") + } +} + +func TestHeaderNameColonRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + Header("X-Bad:Name", "value"). + Build() + if err == nil { + t.Error("Header with colon in name: expected error, got nil") + } +} + +func TestHeaderNameCRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + Header("X-Bad\r\nBcc", "evil@evil.com"). + Build() + if err == nil { + t.Error("Header with CRLF in name: expected error, got nil") + } +} + +func TestHeaderValueCRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + Header("X-Custom", "legit\r\nBcc: evil@evil.com"). + Build() + if err == nil { + t.Error("Header with CRLF in value: expected error, got nil") + } +} + +func TestFromDisplayNameCRLFRejected(t *testing.T) { + _, err := New(). + From("Alice\r\nBcc: evil@evil.com", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + Build() + if err == nil { + t.Error("From with CRLF in display name: expected error, got nil") + } +} + +func TestToDisplayNameCRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("Bob\r\nBcc: evil@evil.com", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + Build() + if err == nil { + t.Error("To with CRLF in display name: expected error, got nil") + } +} + +func TestAddAttachmentContentTypeCRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + AddAttachment([]byte("data"), "application/pdf\r\nBcc: evil@evil.com", "file.pdf"). + Build() + if err == nil { + t.Error("AddAttachment with CRLF in contentType: expected error, got nil") + } +} + +func TestAddAttachmentFileNameCRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + AddAttachment([]byte("data"), "application/pdf", "file.pdf\r\nBcc: evil@evil.com"). + Build() + if err == nil { + t.Error("AddAttachment with CRLF in fileName: expected error, got nil") + } +} + +func TestAddInlineContentTypeCRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + AddInline([]byte("data"), "image/png\r\nBcc: evil@evil.com", "img.png", "cid1"). + Build() + if err == nil { + t.Error("AddInline with CRLF in contentType: expected error, got nil") + } +} + +func TestAddInlineContentIDCRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + AddInline([]byte("data"), "image/png", "img.png", "cid1\r\nBcc: evil@evil.com"). + Build() + if err == nil { + t.Error("AddInline with CRLF in contentID: expected error, got nil") + } +} + +func TestAddInlineFileNameCRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + AddInline([]byte("data"), "image/png", "img.png\r\nBcc: evil@evil.com", "cid1"). + Build() + if err == nil { + t.Error("AddInline with CRLF in fileName: expected error, got nil") + } +} + +func TestAddOtherPartFileNameCRLFRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + AddOtherPart([]byte("data"), "application/octet-stream", "file.bin\r\nBcc: evil@evil.com", ""). + Build() + if err == nil { + t.Error("AddOtherPart with CRLF in fileName: expected error, got nil") + } +} + +func TestAddInlineContentIDControlCharRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + AddInline([]byte("data"), "image/png", "img.png", "cid1\x01evil"). + Build() + if err == nil { + t.Error("AddInline with control char (0x01) in contentID: expected error, got nil") + } +} + +func TestAddOtherPartContentIDControlCharRejected(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + AddOtherPart([]byte("data"), "application/octet-stream", "file.bin", "cid1\x09evil"). + Build() + if err == nil { + t.Error("AddOtherPart with control char (tab/0x09) in contentID: expected error, got nil") + } +} + +func TestHeaderValueControlCharRejected(t *testing.T) { + cases := []struct { + name string + value string + }{ + {"null byte", "hello\x00world"}, + {"ESC", "hello\x1bworld"}, + {"DEL", "hello\x7fworld"}, + {"CR", "hello\rworld"}, + {"LF", "hello\nworld"}, + {"CRLF", "hello\r\nworld"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + Header("X-Custom", tc.value). + Build() + if err == nil { + t.Errorf("Header with %s in value: expected error, got nil", tc.name) + } + }) + } +} + +func TestHeaderValueDangerousUnicodeRejected(t *testing.T) { + cases := []struct { + name string + value string + }{ + {"Bidi RLO (U+202E)", "file\u202Etxt.exe"}, + {"zero-width space (U+200B)", "hello\u200Bworld"}, + {"BOM (U+FEFF)", "hello\uFEFFworld"}, + {"line separator (U+2028)", "hello\u2028world"}, + {"Bidi isolate LRI (U+2066)", "hello\u2066world"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + Header("X-Custom", tc.value). + Build() + if err == nil { + t.Errorf("Header with %s in value: expected error, got nil", tc.name) + } + }) + } +} + +// ── blocked extension via AddFileAttachment ─────────────────────────────────── + +func TestAddFileAttachmentBlockedExtension(t *testing.T) { + dir := t.TempDir() + orig, _ := os.Getwd() + os.Chdir(dir) + t.Cleanup(func() { os.Chdir(orig) }) + + blocked := []string{"malware.exe", "script.BAT", "payload.js", "hack.ps1", "app.msi"} + for _, name := range blocked { + os.WriteFile(name, []byte("content"), 0o644) + } + for _, name := range blocked { + t.Run(name, func(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + AddFileAttachment(name). + Build() + if err == nil { + t.Fatalf("expected blocked extension error for %q", name) + } + if !strings.Contains(err.Error(), "not allowed") { + t.Fatalf("error = %v, want 'not allowed' message", err) + } + }) + } +} + +func TestAddFileInlineBlockedFormat(t *testing.T) { + dir := t.TempDir() + orig, _ := os.Getwd() + os.Chdir(dir) + t.Cleanup(func() { os.Chdir(orig) }) + + // PNG magic bytes but .svg extension → rejected (bad extension) + pngContent := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} + os.WriteFile("icon.svg", pngContent, 0o644) + // .png extension but EXE content → rejected (bad content) + os.WriteFile("evil.png", []byte("MZ"), 0o644) + + for _, name := range []string{"icon.svg", "evil.png"} { + t.Run(name, func(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + HTMLBody([]byte(``)). + AddFileInline(name, "img1"). + Build() + if err == nil { + t.Fatalf("expected inline format error for %q", name) + } + }) + } +} + +func TestAddFileInlineAllowedFormat(t *testing.T) { + dir := t.TempDir() + orig, _ := os.Getwd() + os.Chdir(dir) + t.Cleanup(func() { os.Chdir(orig) }) + + pngContent := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} + jpegContent := []byte{0xFF, 0xD8, 0xFF, 0xE0} + os.WriteFile("logo.png", pngContent, 0o644) + os.WriteFile("photo.jpg", jpegContent, 0o644) + + for _, name := range []string{"logo.png", "photo.jpg"} { + t.Run(name, func(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + HTMLBody([]byte(``)). + AddFileInline(name, "img1"). + Build() + if err != nil { + t.Fatalf("expected %q to be allowed, got: %v", name, err) + } + }) + } +} + +func TestAddFileAttachmentAllowedExtension(t *testing.T) { + dir := t.TempDir() + orig, _ := os.Getwd() + os.Chdir(dir) + t.Cleanup(func() { os.Chdir(orig) }) + + allowed := []string{"report.pdf", "photo.jpg", "data.csv", "page.html"} + for _, name := range allowed { + os.WriteFile(name, []byte("content"), 0o644) + } + for _, name := range allowed { + t.Run(name, func(t *testing.T) { + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + AddFileAttachment(name). + Build() + if err != nil { + t.Fatalf("expected %q to be allowed, got: %v", name, err) + } + }) + } +} + +func TestHeaderValueTabAllowed(t *testing.T) { + // Tab (\t) is valid in folded header values per RFC 5322 + _, err := New(). + From("", "alice@example.com"). + To("", "bob@example.com"). + Subject("test"). + Date(fixedDate). + MessageID("test@x"). + TextBody([]byte("hi")). + Header("X-Custom", "hello\tworld"). + Build() + if err != nil { + t.Errorf("Header with tab in value: expected no error, got %v", err) + } +} diff --git a/shortcuts/mail/filecheck/filecheck.go b/shortcuts/mail/filecheck/filecheck.go new file mode 100644 index 00000000..e382100c --- /dev/null +++ b/shortcuts/mail/filecheck/filecheck.go @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package filecheck provides mail attachment file validation utilities shared +// by the emlbuilder and draft packages. +package filecheck + +import ( + "fmt" + "net/http" + "path/filepath" + "strings" +) + +// blockedExtensions is the set of file extensions that are not allowed as mail +// attachments. These are potentially harmful executable or script types that +// should be blocked for security reasons (htm/html are intentionally excluded). +var blockedExtensions = map[string]struct{}{ + "action": {}, + "apk": {}, + "app": {}, + "applescript": {}, + "asp": {}, + "awk": {}, + "bash": {}, + "bat": {}, + "bin": {}, + "cdxml": {}, + "chm": {}, + "cmd": {}, + "coffee": {}, + "com": {}, + "command": {}, + "cpl": {}, + "csh": {}, + "dart": {}, + "dll": {}, + "es": {}, + "exe": {}, + "fish": {}, + "gadget": {}, + "go": {}, + "hta": {}, + "inf1": {}, + "ins": {}, + "inx": {}, + "ipa": {}, + "isu": {}, + "jar": {}, + "job": {}, + "js": {}, + "jse": {}, + "ksh": {}, + "lnk": {}, + "lua": {}, + "msc": {}, + "msh": {}, + "msh1": {}, + "msh1xml": {}, + "msh2": {}, + "msh2xml": {}, + "mshxml": {}, + "msi": {}, + "msp": {}, + "mst": {}, + "msu": {}, + "osx": {}, + "out": {}, + "paf": {}, + "php": {}, + "pif": {}, + "pl": {}, + "plist": {}, + "pls": {}, + "pm": {}, + "prg": {}, + "ps": {}, + "ps1": {}, + "ps1xml": {}, + "ps2": {}, + "ps2xml": {}, + "psc1": {}, + "psc2": {}, + "psd1": {}, + "psdm1": {}, + "psm1": {}, + "pssc": {}, + "py": {}, + "pyc": {}, + "pyo": {}, + "pyw": {}, + "pyz": {}, + "pyzw": {}, + "rb": {}, + "reg": {}, + "rgs": {}, + "run": {}, + "scf": {}, + "scr": {}, + "sct": {}, + "sh": {}, + "shb": {}, + "shs": {}, + "tcsh": {}, + "terminal": {}, + "ts": {}, + "tsx": {}, + "u3p": {}, + "vb": {}, + "vbe": {}, + "vbs": {}, + "vbscript": {}, + "ws": {}, + "wsc": {}, + "wsf": {}, + "wsh": {}, + "zsh": {}, +} + +// CheckBlockedExtension returns an error if the filename has a blocked extension. +func CheckBlockedExtension(filename string) error { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) + if ext == "" { + return nil + } + if _, ok := blockedExtensions[ext]; ok { + return fmt.Errorf("file extension %q is not allowed as a mail attachment", "."+ext) + } + return nil +} + +// allowedInlineExtensions is the whitelist of file extensions allowed for +// inline images. Only well-supported image formats are included. +var allowedInlineExtensions = map[string]struct{}{ + "jpg": {}, + "jpeg": {}, + "png": {}, + "gif": {}, + "webp": {}, +} + +// allowedInlineMIMETypes is the whitelist of MIME types allowed for inline +// images, checked via content sniffing (http.DetectContentType). +var allowedInlineMIMETypes = map[string]struct{}{ + "image/jpeg": {}, + "image/png": {}, + "image/gif": {}, + "image/webp": {}, +} + +// CheckInlineImageFormat validates that the file is an allowed inline image +// format by checking both extension and content-sniffed MIME type. +// Both must match the whitelist to prevent extension spoofing and MIME forgery. +// On success it returns the detected MIME type; callers MUST use this as the +// final Content-Type instead of trusting any user-supplied or inherited value. +func CheckInlineImageFormat(filename string, content []byte) (string, error) { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), ".")) + if _, ok := allowedInlineExtensions[ext]; !ok { + return "", fmt.Errorf("inline image extension %q is not allowed; supported formats: jpg, jpeg, png, gif, webp", ext) + } + detected := http.DetectContentType(content) + // DetectContentType may return params (e.g. "text/plain; charset=utf-8"), + // strip to the base media type. + if i := strings.IndexByte(detected, ';'); i != -1 { + detected = strings.TrimSpace(detected[:i]) + } + if _, ok := allowedInlineMIMETypes[detected]; !ok { + return "", fmt.Errorf("inline image content type %q does not match an allowed image format; supported: image/jpeg, image/png, image/gif, image/webp", detected) + } + return detected, nil +} diff --git a/shortcuts/mail/filecheck/filecheck_test.go b/shortcuts/mail/filecheck/filecheck_test.go new file mode 100644 index 00000000..5d8dab7b --- /dev/null +++ b/shortcuts/mail/filecheck/filecheck_test.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package filecheck + +import ( + "testing" +) + +func TestCheckBlockedExtension(t *testing.T) { + tests := []struct { + filename string + blocked bool + }{ + // Blocked extensions + {"malware.exe", true}, + {"script.bat", true}, + {"payload.cmd", true}, + {"trojan.scr", true}, + {"installer.msi", true}, + {"hack.ps1", true}, + {"code.js", true}, + {"code.py", true}, + {"code.sh", true}, + {"code.vbs", true}, + {"package.jar", true}, + {"binary.dll", true}, + {"link.lnk", true}, + {"helper.hta", true}, + {"app.ipa", true}, + {"app.apk", true}, + + // Case insensitivity + {"VIRUS.EXE", true}, + {"Script.Bat", true}, + {"CODE.JS", true}, + + // Allowed extensions + {"report.pdf", false}, + {"photo.jpg", false}, + {"image.png", false}, + {"document.docx", false}, + {"spreadsheet.xlsx", false}, + {"archive.zip", false}, + {"data.csv", false}, + {"email.eml", false}, + {"notes.txt", false}, + {"page.html", false}, + {"page.htm", false}, + + // No extension / dot files + {"Makefile", false}, + {".gitignore", false}, + {"README", false}, + } + + for _, tt := range tests { + t.Run(tt.filename, func(t *testing.T) { + err := CheckBlockedExtension(tt.filename) + if tt.blocked && err == nil { + t.Errorf("expected %q to be blocked", tt.filename) + } + if !tt.blocked && err != nil { + t.Errorf("expected %q to be allowed, got: %v", tt.filename, err) + } + }) + } +} + +// Minimal valid file headers for content sniffing. +var ( + pngHeader = []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A} + jpegHeader = []byte{0xFF, 0xD8, 0xFF, 0xE0} + gifHeader = []byte("GIF89a") + webpHeader = append([]byte("RIFF\x00\x00\x00\x00WEBPVP8 "), make([]byte, 20)...) + pdfHeader = []byte("%PDF-1.4") + exeHeader = []byte("MZ") +) + +func TestCheckInlineImageFormat(t *testing.T) { + tests := []struct { + name string + file string + content []byte + wantErr bool + }{ + // Allowed: extension + content both match + {"png ok", "logo.png", pngHeader, false}, + {"jpg ok", "photo.jpg", jpegHeader, false}, + {"jpeg ok", "photo.jpeg", jpegHeader, false}, + {"gif ok", "anim.gif", gifHeader, false}, + {"webp ok", "image.webp", webpHeader, false}, + {"PNG uppercase", "LOGO.PNG", pngHeader, false}, + + // Rejected: wrong extension, valid image content + {"svg ext + png content", "icon.svg", pngHeader, true}, + {"bmp ext + png content", "icon.bmp", pngHeader, true}, + {"tiff ext + png content", "icon.tiff", pngHeader, true}, + {"txt ext + png content", "file.txt", pngHeader, true}, + + // Rejected: valid extension, wrong content (spoofed) + {"png ext + exe content", "evil.png", exeHeader, true}, + {"jpg ext + pdf content", "evil.jpg", pdfHeader, true}, + {"gif ext + plain text", "evil.gif", []byte("not an image"), true}, + + // Rejected: both wrong + {"exe ext + exe content", "malware.exe", exeHeader, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ct, err := CheckInlineImageFormat(tt.file, tt.content) + if tt.wantErr && err == nil { + t.Errorf("expected error for %q", tt.name) + } + if !tt.wantErr && err != nil { + t.Errorf("expected no error for %q, got: %v", tt.name, err) + } + if !tt.wantErr && ct == "" { + t.Errorf("expected non-empty content type for %q", tt.name) + } + }) + } +} diff --git a/shortcuts/mail/helpers.go b/shortcuts/mail/helpers.go new file mode 100644 index 00000000..4c8c3fcf --- /dev/null +++ b/shortcuts/mail/helpers.go @@ -0,0 +1,1892 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + netmail "net/mail" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/mail/emlbuilder" +) + +// hintIdentityFirst prints a one-line tip to stderr for read-only mail shortcuts +// that don't internally call user_mailboxes.profile. This helps models and users +// discover the identity-first workflow without needing skill documentation. +func hintIdentityFirst(runtime *common.RuntimeContext, mailboxID string) { + fmt.Fprintf(runtime.IO().ErrOut, + "tip: run \"lark-cli mail user_mailboxes profile --params '{\"user_mailbox_id\":\"%s\"}'\" to confirm your email identity\n", + sanitizeForTerminal(mailboxID)) +} + +// hintSendDraft prints a post-draft-save tip to stderr telling the user +// (or the calling agent) how to send the draft that was just created. +func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) { + fmt.Fprintf(runtime.IO().ErrOut, + "tip: draft saved. To send this draft, run:\n"+ + ` lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`+"\n", + sanitizeForTerminal(mailboxID), sanitizeForTerminal(draftID)) +} + +// hintMarkAsRead prints a post-send tip to stderr suggesting the user mark the +// original message as read after a reply/reply-all/forward operation. +func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) { + fmt.Fprintf(runtime.IO().ErrOut, + "tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+ + ` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n", + sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID)) +} + +// messageOutputSchema returns a JSON description of +message / +messages / +thread output fields. +// Used by --print-output-schema to let callers discover field names without reading skill docs. +func printMessageOutputSchema(runtime *common.RuntimeContext) { + schema := map[string]interface{}{ + "_description": "Output field reference for mail +message / +messages / +thread", + "fields": map[string]string{ + "message_id": "Email message ID", + "thread_id": "Thread ID", + "subject": "Email subject", + "head_from": "Sender object: {mail_address, name}", + "to": "To recipients: [{mail_address, name}]", + "cc": "CC recipients: [{mail_address, name}]", + "bcc": "BCC recipients: [{mail_address, name}]", + "date": "Time in EML (milliseconds)", + "date_formatted": "Human-readable send time, e.g. '2026-03-19 16:33'", + "smtp_message_id": "SMTP Message-ID conforming to RFC 2822", + "in_reply_to": "In-Reply-To email header", + "references": "References email header, list of ancestor SMTP message IDs", + "internal_date": "Create/receive/send time (milliseconds)", + "message_state": "Message state: 1 = received, 2 = sent, 3 = draft", + "message_state_text": "unknown / received / sent / draft", + "folder_id": "Folder ID. Values: INBOX, SENT, SPAM, ARCHIVED, STRANGER, or custom folder ID", + "label_ids": "List of label IDs", + "priority_type": "Priority value. Values: 0 = no priority, 1 = high, 3 = normal, 5 = low", + "priority_type_text": "unknown / high / normal / low", + "security_level": "Security/risk assessment object; present when the server has risk metadata", + "security_level.is_risk": "Boolean. true if the message is flagged as risky", + "security_level.risk_banner_level": "Risk severity. Values: WARNING (warning), DANGER (danger), INFO (informational)", + "security_level.risk_banner_reason": "Risk reason. Values: NO_REASON, IMPERSONATE_DOMAIN (similar-domain spoofing), IMPERSONATE_KP_NAME (key-person name spoofing), UNAUTH_EXTERNAL (unauthenticated external domain), MALICIOUS_URL, MALICIOUS_ATTACHMENT, PHISHING, IMPERSONATE_PARTNER (partner spoofing), EXTERNAL_ENCRYPTION_ATTACHMENT (external encrypted attachment)", + "security_level.is_header_from_external": "Boolean. true if the sender is from an external domain", + "security_level.via_domain": "SPF/DKIM domain shown when the email is sent on behalf of or forged, e.g. 'larksuite.com'", + "security_level.spam_banner_type": "Spam reason. Values: USER_REPORT (user reported spam), USER_BLOCK (sender blocked by user), ANTI_SPAM (system classified as spam), USER_RULE (matched inbox rule into spam), BLOCK_DOMIN (domain blocked by user), BLOCK_ADDRESS (address blocked by user)", + "security_level.spam_user_rule_id": "ID of the matched inbox rule", + "security_level.spam_banner_info": "Address or domain that matched the user's blocklist, e.g. 'larksuite.com'", + "draft_id": "Draft ID, obtainable via list drafts API", + "reply_to": "Reply-To email header", + "reply_to_smtp_message_id": "Reply-To SMTP Message-ID", + "body_plain_text": "Preferred body field for LLM reading; base64url-decoded and ANSI-sanitized", + "body_preview": "First 100 characters of plaintext body content, for quick preview of core email content", + "body_html": "Raw HTML body; omitted when --html=false", + "attachments": "Unified list of regular attachments and inline images", + "attachments[].id": "Attachment ID (use with download_url API)", + "attachments[].filename": "Attachment filename", + "attachments[].content_type": "MIME content type of the attachment", + "attachments[].attachment_type": "Attachment type. Values: 1 = normal, 2 = large attachment", + "attachments[].is_inline": "true = inline image, false = regular attachment", + "attachments[].cid": "Content-ID for inline images (maps to )", + }, + "thread_extra_fields": map[string]string{ + "thread_id": "Thread ID", + "message_count": "Number of messages in thread", + "messages": "Message array sorted by internal_date ascending (oldest first)", + }, + "messages_extra_fields": map[string]string{ + "total": "Number of successfully returned messages", + "unavailable_message_ids": "Requested IDs not returned by the API", + }, + } + runtime.Out(schema, nil) +} + +// printWatchOutputSchema prints the per-format field reference for +watch output. +// Used by --print-output-schema to let callers discover field names without reading skill docs. +func printWatchOutputSchema(runtime *common.RuntimeContext) { + schema := map[string]interface{}{ + "minimal": map[string]interface{}{ + "message": map[string]interface{}{ + "message_id": "", + "thread_id": "", + "folder_id": "INBOX", + "label_ids": []string{"UNREAD", "IMPORTANT"}, + "internal_date": "1700000000000", + "message_state": 1, + }, + }, + "metadata": map[string]interface{}{ + "message": map[string]interface{}{ + "message_id": "", + "thread_id": "", + "subject": "", + "head_from": map[string]string{"mail_address": "
", "name": ""}, + "to": []map[string]string{{"mail_address": "
", "name": ""}}, + "body_preview": "", + "internal_date": "1700000000000", + "folder_id": "INBOX", + "label_ids": []string{"UNREAD", "IMPORTANT"}, + "message_state": 1, + "in_reply_to": "", + "references": "", + "reply_to": "", + "smtp_message_id": "", + "security_level": map[string]bool{"is_risk": false}, + "attachments": []interface{}{}, + }, + }, + "plain_text_full": map[string]interface{}{ + "message": map[string]interface{}{ + "_note": "all fields from metadata, plus:", + "body_plain_text": "", + }, + }, + "full": map[string]interface{}{ + "message": map[string]interface{}{ + "_note": "all fields from plain_text_full, plus:", + "body_html": "", + "attachments": []map[string]interface{}{ + { + "id": "", + "filename": "", + "content_type": "", + "is_inline": false, + "cid": "", + "attachment_type": 1, + }, + }, + }, + }, + "event": map[string]interface{}{ + "header": map[string]string{ + "event_id": "", + "create_time": "1700000000000", + }, + "event": map[string]interface{}{ + "mail_address": "
", + "message_id": "", + "mailbox_type": 1, + }, + }, + } + b, _ := json.MarshalIndent(schema, "", " ") + fmt.Fprintln(runtime.IO().Out, string(b)) +} + +// resolveMailboxID returns the user_mailbox_id from --mailbox flag, defaulting to "me". +func resolveMailboxID(runtime *common.RuntimeContext) string { + id := runtime.Str("mailbox") + if id == "" { + return "me" + } + return id +} + +// resolveComposeMailboxID returns the mailbox ID for compose shortcuts, +// derived from --from flag. Falls back to "me" when --from is not specified. +func resolveComposeMailboxID(runtime *common.RuntimeContext) string { + if from := runtime.Str("from"); from != "" { + return from + } + return "me" +} + +// mailboxPath builds the full open-api path for a user mailbox sub-resource. +// Each path segment is escaped independently to avoid reserved-char path breakage. +func mailboxPath(mailboxID string, segments ...string) string { + parts := make([]string, 0, len(segments)+1) + parts = append(parts, url.PathEscape(mailboxID)) + for _, seg := range segments { + if seg == "" { + continue + } + parts = append(parts, url.PathEscape(seg)) + } + return "/open-apis/mail/v1/user_mailboxes/" + strings.Join(parts, "/") +} + +// fetchMailboxPrimaryEmail retrieves mailbox primary_email_address from +// user_mailboxes.profile. Returns empty string on failure (non-fatal). +func fetchMailboxPrimaryEmail(runtime *common.RuntimeContext, mailboxID string) string { + if mailboxID == "" { + mailboxID = "me" + } + data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "profile"), nil, nil) + if err != nil { + return "" + } + if email := extractPrimaryEmail(data); email != "" { + return email + } + if nested, ok := data["data"].(map[string]interface{}); ok { + if email := extractPrimaryEmail(nested); email != "" { + return email + } + } + return "" +} + +func extractPrimaryEmail(data map[string]interface{}) string { + if email, ok := data["primary_email_address"].(string); ok && strings.TrimSpace(email) != "" { + return strings.TrimSpace(email) + } + if mailbox, ok := data["user_mailbox"].(map[string]interface{}); ok { + if email, ok := mailbox["primary_email_address"].(string); ok && strings.TrimSpace(email) != "" { + return strings.TrimSpace(email) + } + } + return "" +} + +// fetchCurrentUserEmail retrieves the current mailbox primary email. +func fetchCurrentUserEmail(runtime *common.RuntimeContext) string { + return fetchMailboxPrimaryEmail(runtime, "me") +} + +// fetchSelfEmailSet returns a set containing the primary email of the given +// mailbox for reply-all exclusion. Pass the resolved mailboxID (from +// resolveComposeMailboxID) so that when --from selects a different mailbox, +// only that mailbox's own address is excluded — not the "me" primary email. +func fetchSelfEmailSet(runtime *common.RuntimeContext, mailboxID string) map[string]bool { + if mailboxID == "" { + mailboxID = "me" + } + set := make(map[string]bool) + if email := fetchMailboxPrimaryEmail(runtime, mailboxID); email != "" { + set[strings.ToLower(email)] = true + } + return set +} + +// folderAliasToSystemID maps friendly folder alias to system folder ID. +var folderAliasToSystemID = map[string]string{ + "inbox": "INBOX", + "sent": "SENT", + "draft": "DRAFT", + "trash": "TRASH", + "spam": "SPAM", + "archive": "ARCHIVED", + "archived": "ARCHIVED", +} + +// folderSystemIDToAlias maps system folder IDs to the search API query names. +// Note: the search API uses "archive" (not "archived") for the ARCHIVED folder. +var folderSystemIDToAlias = map[string]string{ + "INBOX": "inbox", + "SENT": "sent", + "DRAFT": "draft", + "TRASH": "trash", + "SPAM": "spam", + "ARCHIVED": "archive", +} + +// searchOnlyFolderNames are folder names accepted only by the search API, +// not present in the folder list API. They are passed through as-is. +var searchOnlyFolderNames = map[string]bool{ + "scheduled": true, +} + +// folderSystemIDs are known built-in folder IDs that can be passed directly. +var folderSystemIDs = map[string]bool{ + "INBOX": true, + "SENT": true, + "DRAFT": true, + "TRASH": true, + "SPAM": true, + "ARCHIVED": true, +} + +// labelSystemIDs are known built-in label IDs that can be passed directly. +var labelSystemIDs = map[string]bool{ + "FLAGGED": true, + "IMPORTANT": true, + "OTHER": true, +} + +// systemLabelAliases maps all recognized user inputs (lowercase) to canonical system label IDs. +// These system labels can be passed via either --filter folder or --filter label. +// On search path they are sent as folder values; on list path they are sent as label_id. +var systemLabelAliases = map[string]string{ + // IMPORTANT + "important": "IMPORTANT", + "priority": "IMPORTANT", + "重要邮件": "IMPORTANT", + // FLAGGED + "flagged": "FLAGGED", + "已加旗标": "FLAGGED", + // OTHER + "other": "OTHER", + "其他邮件": "OTHER", +} + +// systemLabelSearchName maps system label IDs to the search API folder values. +// Note: the search API uses "priority" (not "important") for the IMPORTANT label. +var systemLabelSearchName = map[string]string{ + "FLAGGED": "flagged", + "IMPORTANT": "priority", + "OTHER": "other", +} + +// resolveSystemLabel checks if input is a system label alias (case-insensitive). +// Returns the canonical system label ID and true, or ("", false). +func resolveSystemLabel(input string) (string, bool) { + if id, ok := systemLabelAliases[strings.ToLower(strings.TrimSpace(input))]; ok { + return id, true + } + // Also check uppercase form directly (e.g. "FLAGGED", "IMPORTANT", "OTHER"). + if id, ok := normalizeSystemID(input, labelSystemIDs); ok { + return id, true + } + return "", false +} + +type folderInfo struct { + ID string + Name string + ParentFolderID string +} + +type labelInfo struct { + ID string + Name string +} + +func resolveFolderID(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + return "", nil + } + if id, ok := normalizeSystemID(value, folderSystemIDs); ok { + return id, nil + } + folders, err := listMailboxFolders(runtime, mailboxID) + if err != nil { + return "", err + } + return resolveByID("folder", value, mailboxID, folders, func(item folderInfo) string { return item.ID }) +} + +func resolveFolderName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + return "", nil + } + if id, ok := resolveFolderSystemAliasOrID(value); ok { + return id, nil + } + folders, err := listMailboxFolders(runtime, mailboxID) + if err != nil { + return "", err + } + return resolveByName("folder", value, mailboxID, folders, + func(item folderInfo) string { return item.ID }, + func(item folderInfo) string { return item.Name }, + ) +} + +func resolveLabelID(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + return "", nil + } + if id, ok := resolveLabelSystemID(value); ok { + return id, nil + } + labels, err := listMailboxLabels(runtime, mailboxID) + if err != nil { + return "", err + } + return resolveByID("label", value, mailboxID, labels, func(item labelInfo) string { return item.ID }) +} + +func resolveLabelName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + return "", nil + } + if id, ok := resolveLabelSystemID(value); ok { + return id, nil + } + labels, err := listMailboxLabels(runtime, mailboxID) + if err != nil { + return "", err + } + id, err := resolveByName("label", value, mailboxID, labels, + func(item labelInfo) string { return item.ID }, + func(item labelInfo) string { return item.Name }, + ) + if err != nil { + if matchID := matchLabelSuffixID(value, labels); matchID != "" { + return matchID, nil + } + return "", err + } + return id, nil +} + +func resolveFolderQueryName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + return "", nil + } + if searchOnlyFolderNames[strings.ToLower(value)] { + return strings.ToLower(value), nil + } + if id, ok := resolveFolderSystemAliasOrID(value); ok { + return folderSystemIDToAlias[id], nil + } + folders, err := listMailboxFolders(runtime, mailboxID) + if err != nil { + return "", err + } + name, err := resolveNameValueByNameAllowDuplicates("folder", value, mailboxID, folders, + func(item folderInfo) string { return item.ID }, + func(item folderInfo) string { return item.Name }, + ) + if err != nil { + return "", err + } + return folderSearchPath(name, value, folders), nil +} + +func resolveFolderQueryNameFromID(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + return "", nil + } + if id, ok := resolveFolderSystemAliasOrID(value); ok { + return folderSystemIDToAlias[id], nil + } + folders, err := listMailboxFolders(runtime, mailboxID) + if err != nil { + return "", err + } + name, err := resolveNameValueByID("folder", value, mailboxID, folders, + func(item folderInfo) string { return item.ID }, + func(item folderInfo) string { return item.Name }, + ) + if err != nil { + return "", err + } + return folderSearchPath(name, value, folders), nil +} + +// folderSearchPath returns the search API folder path for a resolved folder name. +// For subfolders, the search API requires "parent_name/child_name" format. +func folderSearchPath(resolvedName, input string, folders []folderInfo) string { + lower := strings.ToLower(strings.TrimSpace(input)) + for _, f := range folders { + if strings.ToLower(f.Name) != lower && f.ID != input { + continue + } + if f.ParentFolderID == "" || f.ParentFolderID == "0" { + return resolvedName + } + for _, parent := range folders { + if parent.ID == f.ParentFolderID { + return parent.Name + "/" + resolvedName + } + } + return resolvedName + } + return resolvedName +} + +func resolveLabelQueryName(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + return "", nil + } + if id, ok := resolveLabelSystemID(value); ok { + return systemLabelSearchName[id], nil + } + labels, err := listMailboxLabels(runtime, mailboxID) + if err != nil { + return "", err + } + name, err := resolveNameValueByNameAllowDuplicates("label", value, mailboxID, labels, + func(item labelInfo) string { return item.ID }, + func(item labelInfo) string { return item.Name }, + ) + if err != nil { + // Sub-label names contain the full path (e.g. "parent/child"). + // If exact match fails, try suffix match for child label names. + if match := matchLabelSuffix(value, labels); match != "" { + return match, nil + } + return "", err + } + return name, nil +} + +func resolveLabelQueryNameFromID(runtime *common.RuntimeContext, mailboxID, input string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + return "", nil + } + if id, ok := resolveLabelSystemID(value); ok { + return systemLabelSearchName[id], nil + } + labels, err := listMailboxLabels(runtime, mailboxID) + if err != nil { + return "", err + } + return resolveNameValueByID("label", value, mailboxID, labels, + func(item labelInfo) string { return item.ID }, + func(item labelInfo) string { return item.Name }, + ) +} + +// matchLabelSuffix finds a label whose name ends with "/input" (case-insensitive) +// and returns the full label name. Used for search path resolution. +func matchLabelSuffix(input string, labels []labelInfo) string { + lower := strings.ToLower(input) + suffix := "/" + lower + for _, l := range labels { + name := strings.TrimSpace(l.Name) + if strings.HasSuffix(strings.ToLower(name), suffix) { + return name + } + } + return "" +} + +// matchLabelSuffixID finds a label whose name ends with "/input" (case-insensitive) +// and returns the label ID. Used for list path resolution. +func matchLabelSuffixID(input string, labels []labelInfo) string { + lower := strings.ToLower(input) + suffix := "/" + lower + for _, l := range labels { + name := strings.TrimSpace(l.Name) + if strings.HasSuffix(strings.ToLower(name), suffix) { + return l.ID + } + } + return "" +} + +func resolveFolderNames(runtime *common.RuntimeContext, mailboxID string, values []string) ([]string, error) { + resolved := make([]string, 0, len(values)) + seen := make(map[string]bool) + names := make([]string, 0, len(values)) + for _, raw := range values { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + if id, ok := resolveFolderSystemAliasOrID(value); ok { + addUniqueID(&resolved, seen, id) + continue + } + names = append(names, value) + } + if len(names) == 0 { + return resolved, nil + } + + folders, err := listMailboxFolders(runtime, mailboxID) + if err != nil { + return nil, err + } + for _, value := range names { + id, err := resolveByName("folder", value, mailboxID, folders, + func(item folderInfo) string { return item.ID }, + func(item folderInfo) string { return item.Name }, + ) + if err != nil { + return nil, err + } + addUniqueID(&resolved, seen, id) + } + return resolved, nil +} + +func resolveLabelNames(runtime *common.RuntimeContext, mailboxID string, values []string) ([]string, error) { + resolved := make([]string, 0, len(values)) + seen := make(map[string]bool) + names := make([]string, 0, len(values)) + for _, raw := range values { + value := strings.TrimSpace(raw) + if value == "" { + continue + } + if id, ok := resolveLabelSystemID(value); ok { + addUniqueID(&resolved, seen, id) + continue + } + names = append(names, value) + } + if len(names) == 0 { + return resolved, nil + } + + labels, err := listMailboxLabels(runtime, mailboxID) + if err != nil { + return nil, err + } + for _, value := range names { + id, err := resolveByName("label", value, mailboxID, labels, + func(item labelInfo) string { return item.ID }, + func(item labelInfo) string { return item.Name }, + ) + if err != nil { + return nil, err + } + addUniqueID(&resolved, seen, id) + } + return resolved, nil +} + +func resolveFolderSystemAliasOrID(input string) (string, bool) { + if id, ok := folderAliasToSystemID[strings.ToLower(strings.TrimSpace(input))]; ok { + return id, true + } + return normalizeSystemID(input, folderSystemIDs) +} + +func resolveLabelSystemID(input string) (string, bool) { + return resolveSystemLabel(input) +} + +func normalizeSystemID(input string, systemIDs map[string]bool) (string, bool) { + canonical := strings.ToUpper(strings.TrimSpace(input)) + if canonical == "" { + return "", false + } + if systemIDs[canonical] { + return canonical, true + } + return "", false +} + +func addUniqueID(dst *[]string, seen map[string]bool, id string) { + if id == "" || seen[id] { + return + } + seen[id] = true + *dst = append(*dst, id) +} + +func listMailboxFolders(runtime *common.RuntimeContext, mailboxID string) ([]folderInfo, error) { + data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "folders"), nil, nil) + if err != nil { + return nil, output.ErrValidation("unable to resolve --folder: failed to list folders (%v). %s", err, resolveLookupHint("folder", mailboxID)) + } + items, _ := data["items"].([]interface{}) + folders := make([]folderInfo, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + id := strVal(m["id"]) + if id == "" { + continue + } + folders = append(folders, folderInfo{ID: id, Name: strVal(m["name"]), ParentFolderID: strVal(m["parent_folder_id"])}) + } + return folders, nil +} + +func listMailboxLabels(runtime *common.RuntimeContext, mailboxID string) ([]labelInfo, error) { + data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "labels"), nil, nil) + if err != nil { + return nil, output.ErrValidation("unable to resolve --label: failed to list labels (%v). %s", err, resolveLookupHint("label", mailboxID)) + } + items, _ := data["items"].([]interface{}) + labels := make([]labelInfo, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + id := strVal(m["id"]) + if id == "" { + continue + } + labels = append(labels, labelInfo{ID: id, Name: strVal(m["name"])}) + } + return labels, nil +} + +func resolveByID[T any](kind, input, mailboxID string, items []T, idFn func(T) string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + return "", nil + } + for _, item := range items { + if id := idFn(item); id != "" && id == value { + return id, nil + } + } + return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID)) +} + +func resolveByName[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + return "", nil + } + + for _, item := range items { + if id := idFn(item); id != "" && id == value { + return "", output.ErrValidation("%s %q looks like an ID; please use %s_id", kind, value, kind) + } + } + + lower := strings.ToLower(value) + matches := make([]string, 0, 2) + matchSet := make(map[string]bool) + for _, item := range items { + name := strings.TrimSpace(nameFn(item)) + if name == "" || strings.ToLower(name) != lower { + continue + } + id := idFn(item) + if id == "" || matchSet[id] { + continue + } + matchSet[id] = true + matches = append(matches, id) + } + + if len(matches) == 1 { + return matches[0], nil + } + if len(matches) > 1 { + return "", output.ErrValidation("%s name %q matches multiple IDs (%s); please use an ID", kind, value, strings.Join(matches, ",")) + } + return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID)) +} + +func resolveNameValueByID[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + return "", nil + } + for _, item := range items { + if id := idFn(item); id != "" && id == value { + name := strings.TrimSpace(nameFn(item)) + if name == "" { + return "", output.ErrValidation("%s %q has empty name; cannot use it with query filters", kind, value) + } + return name, nil + } + } + return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID)) +} + +func resolveNameValueByNameAllowDuplicates[T any](kind, input, mailboxID string, items []T, idFn func(T) string, nameFn func(T) string) (string, error) { + value := strings.TrimSpace(input) + if value == "" { + return "", nil + } + for _, item := range items { + if id := idFn(item); id != "" && id == value { + return "", output.ErrValidation("%s %q looks like an ID; please use %s_id", kind, value, kind) + } + } + lower := strings.ToLower(value) + for _, item := range items { + name := strings.TrimSpace(nameFn(item)) + if name == "" || strings.ToLower(name) != lower { + continue + } + return name, nil + } + return "", output.ErrValidation("%s %q not_exists. %s", kind, value, resolveLookupHint(kind, mailboxID)) +} + +func resolveLookupHint(kind, mailboxID string) string { + if mailboxID == "" { + mailboxID = "me" + } + switch kind { + case "folder": + return fmt.Sprintf("Run `lark-cli mail user_mailbox.folders list --params '{\"user_mailbox_id\":\"%s\"}'` to inspect available folder IDs and names.", mailboxID) + case "label": + return fmt.Sprintf("Run `lark-cli api GET '/open-apis/mail/v1/user_mailboxes/%s/labels' --as user` to inspect available label IDs and names.", validate.EncodePathSegment(mailboxID)) + default: + return "" + } +} + +// fetchFullMessage calls message.get. +// html=true -> format=full +// html=false -> format=plain_text_full (server omits body_html) +func fetchFullMessage(runtime *common.RuntimeContext, mailboxID, messageID string, html bool) (map[string]interface{}, error) { + params := map[string]interface{}{"format": messageGetFormat(html)} + data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "messages", messageID), params, nil) + if err != nil { + return nil, err + } + msg, _ := data["message"].(map[string]interface{}) + if msg == nil { + return nil, fmt.Errorf("API response missing message field") + } + return msg, nil +} + +// fetchFullMessages calls messages.batch_get and preserves the requested ID order. +// It returns the fetched raw message objects plus any IDs not returned by the API. +func fetchFullMessages(runtime *common.RuntimeContext, mailboxID string, messageIDs []string, html bool) ([]map[string]interface{}, []string, error) { + if len(messageIDs) == 0 { + return nil, nil, nil + } + const maxBatchGetMessageIDs = 20 + byID := make(map[string]map[string]interface{}, len(messageIDs)) + for start := 0; start < len(messageIDs); start += maxBatchGetMessageIDs { + end := start + maxBatchGetMessageIDs + if end > len(messageIDs) { + end = len(messageIDs) + } + data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "messages", "batch_get"), nil, map[string]interface{}{ + "format": messageGetFormat(html), + "message_ids": messageIDs[start:end], + }) + if err != nil { + return nil, nil, err + } + rawMessages, _ := data["messages"].([]interface{}) + for _, item := range rawMessages { + msg, ok := item.(map[string]interface{}) + if !ok { + continue + } + messageID := strVal(msg["message_id"]) + if messageID == "" { + continue + } + byID[messageID] = msg + } + } + + ordered := make([]map[string]interface{}, 0, len(messageIDs)) + missing := make([]string, 0) + for _, messageID := range messageIDs { + if msg, ok := byID[messageID]; ok { + ordered = append(ordered, msg) + continue + } + missing = append(missing, messageID) + } + return ordered, missing, nil +} + +func messageGetFormat(html bool) string { + if html { + return "full" + } + return "plain_text_full" +} + +// extractAttachmentIDs returns the attachment IDs from a raw message map. +func extractAttachmentIDs(msg map[string]interface{}) []string { + rawAtts, _ := msg["attachments"].([]interface{}) + ids := make([]string, 0, len(rawAtts)) + for _, item := range rawAtts { + if att, ok := item.(map[string]interface{}); ok { + if id := strVal(att["id"]); id != "" { + ids = append(ids, id) + } + } + } + return ids +} + +type warningEntry struct { + Code string `json:"code"` + Level string `json:"level"` + MessageID string `json:"message_id"` + AttachmentID string `json:"attachment_id"` + Retryable bool `json:"retryable"` + Detail string `json:"detail"` +} + +type mailAddressOutput struct { + Email string `json:"email"` + Name string `json:"name"` +} + +// mailAddressPair is a name+email pair used for display in HTML and plaintext quote blocks. +type mailAddressPair struct { + Email string + Name string +} + +func toAddressPairList(raw []mailAddressOutput) []mailAddressPair { + out := make([]mailAddressPair, 0, len(raw)) + for _, addr := range raw { + if addr.Email != "" { + out = append(out, mailAddressPair{Email: addr.Email, Name: addr.Name}) + } + } + return out +} + +type mailAttachmentOutput struct { + ID string `json:"id"` + Filename string `json:"filename"` + ContentType string `json:"content_type,omitempty"` + AttachmentType int `json:"attachment_type"` + DownloadURL string `json:"download_url,omitempty"` +} + +type mailImageOutput struct { + ID string `json:"id"` + Filename string `json:"filename"` + ContentType string `json:"content_type,omitempty"` + CID string `json:"cid"` + DownloadURL string `json:"download_url,omitempty"` +} + +type mailPublicAttachmentOutput struct { + ID string `json:"id"` + Filename string `json:"filename"` + ContentType string `json:"content_type,omitempty"` + AttachmentType int `json:"attachment_type,omitempty"` + IsInline bool `json:"is_inline"` + CID string `json:"cid,omitempty"` +} + +type mailSecurityLevelOutput struct { + IsRisk bool `json:"is_risk"` + RiskBannerLevel string `json:"risk_banner_level"` + RiskBannerReason string `json:"risk_banner_reason"` + IsHeaderFromExternal bool `json:"is_header_from_external"` + ViaDomain string `json:"via_domain"` + SpamBannerType string `json:"spam_banner_type"` + SpamUserRuleID string `json:"spam_user_rule_id"` + SpamBannerInfo string `json:"spam_banner_info"` +} + +// normalizedMessageForCompose is an internal-only shape used by reply/forward flows. +// It is not the public JSON contract of `mail +message` / `mail +thread`. +type normalizedMessageForCompose struct { + MessageID string `json:"message_id"` + ThreadID string `json:"thread_id"` + SMTPMessageID string `json:"smtp_message_id"` + Subject string `json:"subject"` + From mailAddressOutput `json:"from"` + To []mailAddressOutput `json:"to"` + CC []mailAddressOutput `json:"cc"` + BCC []mailAddressOutput `json:"bcc"` + Date string `json:"date"` + InReplyTo string `json:"in_reply_to"` + ReplyTo string `json:"reply_to,omitempty"` + ReplyToSMTPMessageID string `json:"reply_to_smtp_message_id,omitempty"` + References []string `json:"references"` + InternalDate string `json:"internal_date"` + DateFormatted string `json:"date_formatted"` + MessageState int `json:"message_state"` + MessageStateText string `json:"message_state_text"` + FolderID string `json:"folder_id"` + LabelIDs []string `json:"label_ids"` + PriorityType string `json:"priority_type,omitempty"` + PriorityTypeText string `json:"priority_type_text,omitempty"` + SecurityLevel *mailSecurityLevelOutput `json:"security_level,omitempty"` + BodyPlainText string `json:"body_plain_text"` + BodyPreview string `json:"body_preview"` + BodyHTML string `json:"body_html,omitempty"` + Attachments []mailAttachmentOutput `json:"attachments"` + Images []mailImageOutput `json:"images"` + Warnings []warningEntry `json:"warnings,omitempty"` +} + +// fetchAttachmentURLs fetches download URLs for the given attachment IDs in batches of 20. +// List params are embedded directly in the URL (SDK workaround for repeated query params). +// It never returns an error: failed batches/IDs are converted to structured warnings so caller can continue. +func fetchAttachmentURLs(runtime *common.RuntimeContext, mailboxID, messageID string, ids []string) (map[string]string, []warningEntry) { + callAPI := func(url string) (map[string]interface{}, error) { + return runtime.CallAPI("GET", url, nil, nil) + } + emitWarning := func(w warningEntry) { + fmt.Fprintf(runtime.IO().ErrOut, "warning: code=%s message_id=%s attachment_id=%s retryable=%t detail=%s\n", w.Code, w.MessageID, w.AttachmentID, w.Retryable, w.Detail) + } + return fetchAttachmentURLsWith(runtime, mailboxID, messageID, ids, callAPI, emitWarning) +} + +func fetchAttachmentURLsWith( + runtime *common.RuntimeContext, + mailboxID, messageID string, + ids []string, + callAPI func(url string) (map[string]interface{}, error), + emitWarning func(w warningEntry), +) (map[string]string, []warningEntry) { + if len(ids) == 0 { + return nil, nil + } + urlMap := make(map[string]string, len(ids)) + warnings := make([]warningEntry, 0) + const batchSize = 20 + for i := 0; i < len(ids); i += batchSize { + end := i + batchSize + if end > len(ids) { + end = len(ids) + } + batch := ids[i:end] + + parts := make([]string, len(batch)) + for j, id := range batch { + parts[j] = "attachment_ids=" + url.QueryEscape(id) + } + apiURL := mailboxPath(mailboxID, "messages", messageID, "attachments", "download_url") + + "?" + strings.Join(parts, "&") + + data, err := callAPI(apiURL) + if err != nil { + warn := warningEntry{ + Code: "attachment_download_url_api_error", + Level: "warning", + MessageID: messageID, + AttachmentID: "", + Retryable: true, + Detail: err.Error(), + } + warnings = append(warnings, warn) + emitWarning(warn) + continue + } + + if urls, ok := data["download_urls"].([]interface{}); ok { + for _, item := range urls { + if m, ok := item.(map[string]interface{}); ok { + attID := strVal(m["attachment_id"]) + dlURL := strVal(m["download_url"]) + if attID != "" { + urlMap[attID] = dlURL + } + } + } + } + if failed, ok := data["failed_ids"].([]interface{}); ok { + for _, f := range failed { + if id, ok := f.(string); ok && id != "" { + warn := warningEntry{ + Code: "attachment_download_url_failed_id", + Level: "warning", + MessageID: messageID, + AttachmentID: id, + Retryable: false, + Detail: "attachment id returned in failed_ids", + } + warnings = append(warnings, warn) + emitWarning(warn) + } + } + } + } + return urlMap, warnings +} + +var rawMessageExcludedFields = map[string]struct{}{ + "attachments": {}, +} + +var derivedMessageFields = []string{ + "draft_id", + "body_plain_text", + "body_preview", + "body_html", + "attachments", + "date_formatted", + "message_state_text", + "priority_type_text", +} + +// buildMessageOutput assembles the public shortcut output from a raw message map and attachment URL map. +// +// Output model: +// - raw passthrough: safe message metadata fields that do not need special processing +// - derived fields: decoded body, attachment list, and helper text fields +// +// Raw passthrough excludes: +// - all `body_*` fields +// - `attachments` +// +// Derived fields are listed in `derivedMessageFields`. +func buildMessageOutput(msg map[string]interface{}, html bool) map[string]interface{} { + out := pickSafeMessageFields(msg) + normalized := buildMessageForCompose(msg, nil, html) + + if draftID := derivedDraftID(msg, normalized.MessageID); draftID != "" { + out["draft_id"] = draftID + } + if normalized.ReplyTo != "" { + out["reply_to"] = normalized.ReplyTo + } + if normalized.ReplyToSMTPMessageID != "" { + out["reply_to_smtp_message_id"] = normalized.ReplyToSMTPMessageID + } + out["date_formatted"] = normalized.DateFormatted + out["message_state_text"] = normalized.MessageStateText + if normalized.PriorityType != "" { + out["priority_type_text"] = normalized.PriorityTypeText + } + out["body_plain_text"] = normalized.BodyPlainText + out["body_preview"] = normalized.BodyPreview + if html && normalized.BodyHTML != "" { + out["body_html"] = normalized.BodyHTML + } + out["attachments"] = buildPublicAttachments(msg) + + return out +} + +func buildPublicAttachments(msg map[string]interface{}) []mailPublicAttachmentOutput { + rawAtts, _ := msg["attachments"].([]interface{}) + out := make([]mailPublicAttachmentOutput, 0, len(rawAtts)) + for _, item := range rawAtts { + att, ok := item.(map[string]interface{}) + if !ok { + continue + } + id := strVal(att["id"]) + filename := strVal(att["filename"]) + contentType := resolveAttachmentContentType(att, filename) + isInline, _ := att["is_inline"].(bool) + out = append(out, mailPublicAttachmentOutput{ + ID: id, + Filename: filename, + ContentType: contentType, + AttachmentType: intVal(att["attachment_type"]), + IsInline: isInline, + CID: strVal(att["cid"]), + }) + } + return out +} + +func derivedDraftID(msg map[string]interface{}, messageID string) string { + if draftID := strVal(msg["draft_id"]); draftID != "" { + return draftID + } + if strings.EqualFold(strVal(msg["folder_id"]), "DRAFT") { + return messageID + } + return "" +} + +// buildMessageForCompose assembles the internal normalized message structure used by compose flows. +// - base64url-decodes body fields +// - splits attachments into images (is_inline=true) and attachments (is_inline=false) +// - omits body_html when html=false +// - falls back body_plain_text → body_preview when empty +// - sanitizes body_plain_text for terminal output (strips ANSI escapes and bare CR) +func buildMessageForCompose(msg map[string]interface{}, urlMap map[string]string, html bool) normalizedMessageForCompose { + out := normalizedMessageForCompose{ + MessageID: strVal(msg["message_id"]), + ThreadID: strVal(msg["thread_id"]), + SMTPMessageID: strVal(msg["smtp_message_id"]), + Subject: strVal(msg["subject"]), + From: toAddressObject(msg["head_from"]), + To: toAddressList(msg["to"]), + CC: toAddressList(msg["cc"]), + BCC: toAddressList(msg["bcc"]), + Date: strVal(msg["date"]), + InReplyTo: strVal(msg["in_reply_to"]), + References: toStringList(msg["references"]), + } + out.ReplyTo = strVal(msg["reply_to"]) + out.ReplyToSMTPMessageID = strVal(msg["reply_to_smtp_message_id"]) + + // State + internalDate := strVal(msg["internal_date"]) + out.InternalDate = internalDate + out.DateFormatted = common.FormatTime(internalDate) + state := intVal(msg["message_state"]) + out.MessageState = state + out.MessageStateText = messageStateText(state) + out.FolderID = strVal(msg["folder_id"]) + out.LabelIDs = toStringList(msg["label_ids"]) + priorityType := strVal(msg["priority_type"]) + out.PriorityType = priorityType + if priorityType != "" { + out.PriorityTypeText = priorityTypeText(priorityType) + } + if securityLevel := toSecurityLevel(msg["security_level"]); securityLevel != nil { + out.SecurityLevel = securityLevel + } + + // Body + plainText := decodeBase64URL(strVal(msg["body_plain_text"])) + preview := decodeBase64URL(strVal(msg["body_preview"])) + if plainText == "" { + plainText = preview + } + out.BodyPlainText = sanitizeForTerminal(plainText) + out.BodyPreview = preview + if html { + out.BodyHTML = decodeBase64URL(strVal(msg["body_html"])) + } + + // Attachments + attachments := make([]mailAttachmentOutput, 0) + images := make([]mailImageOutput, 0) + if rawAtts, ok := msg["attachments"].([]interface{}); ok { + for _, item := range rawAtts { + att, ok := item.(map[string]interface{}) + if !ok { + continue + } + id := strVal(att["id"]) + filename := strVal(att["filename"]) + attType := intVal(att["attachment_type"]) + isInline, _ := att["is_inline"].(bool) + cid := strVal(att["cid"]) + contentType := resolveAttachmentContentType(att, filename) + dlURL := urlMap[id] + + if isInline { + images = append(images, mailImageOutput{ + ID: id, + Filename: filename, + ContentType: contentType, + CID: cid, + DownloadURL: dlURL, + }) + } else { + attachments = append(attachments, mailAttachmentOutput{ + ID: id, + Filename: filename, + ContentType: contentType, + AttachmentType: attType, + DownloadURL: dlURL, + }) + } + } + } + out.Attachments = attachments + out.Images = images + + return out +} + +func pickSafeMessageFields(msg map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(msg)) + for key, value := range msg { + if !shouldExposeRawMessageField(key) { + continue + } + out[key] = value + } + return out +} + +func shouldExposeRawMessageField(key string) bool { + if strings.HasPrefix(key, "body_") { + return false + } + _, blocked := rawMessageExcludedFields[key] + return !blocked +} + +// attachmentTypeLarge is the API value for a large attachment that is already +// embedded as a download link inside the message body. These must not be +// downloaded and re-attached during forward: the link in the body is sufficient +// and downloading could cause OOM for very large files. +const attachmentTypeLarge = 2 + +type forwardSourceAttachment struct { + ID string + Filename string + ContentType string + AttachmentType int // 1=normal, 2=large (link in body, skip download) + DownloadURL string +} + +type inlineSourcePart struct { + ID string + Filename string + ContentType string + CID string + DownloadURL string +} + +type composeSourceMessage struct { + Original originalMessage + ForwardAttachments []forwardSourceAttachment + InlineImages []inlineSourcePart +} + +// fetchComposeSourceMessage loads a message via the +message pipeline and converts it +// to compose-friendly data (quote metadata + forward attachments). +func fetchComposeSourceMessage(runtime *common.RuntimeContext, mailboxID, messageID string) (composeSourceMessage, error) { + msg, err := fetchFullMessage(runtime, mailboxID, messageID, true) + if err != nil { + return composeSourceMessage{}, err + } + attIDs := extractAttachmentIDs(msg) + urlMap, _ := fetchAttachmentURLs(runtime, mailboxID, messageID, attIDs) + out := buildMessageForCompose(msg, urlMap, true) + orig := toOriginalMessageForCompose(out) + return composeSourceMessage{ + Original: orig, + ForwardAttachments: toForwardSourceAttachments(out), + InlineImages: toInlineSourceParts(out), + }, nil +} + +// validateForwardAttachmentURLs checks that all forwarded attachments (non-inline) +// have valid download URLs. Inline images are checked separately by validateInlineImageURLs. +func validateForwardAttachmentURLs(src composeSourceMessage) error { + var missing []string + for _, att := range src.ForwardAttachments { + if att.DownloadURL == "" { + missing = append(missing, fmt.Sprintf("attachment %q (%s)", att.Filename, att.ID)) + } + } + if len(missing) > 0 { + return fmt.Errorf("failed to fetch download URLs for: %s", strings.Join(missing, ", ")) + } + return nil +} + +// validateInlineImageURLs checks only inline images have valid download URLs. +// Use for HTML reply/reply-all where inline images are embedded in the quoted body. +func validateInlineImageURLs(src composeSourceMessage) error { + var missing []string + for _, img := range src.InlineImages { + if img.DownloadURL == "" { + missing = append(missing, fmt.Sprintf("inline image %q (%s)", img.Filename, img.ID)) + } + } + if len(missing) > 0 { + return fmt.Errorf("failed to fetch download URLs for: %s", strings.Join(missing, ", ")) + } + return nil +} + +func toOriginalMessageForCompose(out normalizedMessageForCompose) originalMessage { + fromEmail, fromName := out.From.Email, out.From.Name + toList := toAddressEmailList(out.To) + ccList := toAddressEmailList(out.CC) + toFullList := toAddressPairList(out.To) + ccFullList := toAddressPairList(out.CC) + headTo := "" + if len(toList) > 0 { + headTo = toList[0] + } + + headDate := "" + if internalDate := out.InternalDate; internalDate != "" { + if ms, err := strconv.ParseInt(internalDate, 10, 64); err == nil { + headDate = formatMailDate(ms, detectSubjectLang(out.Subject)) + } + } + + bodyHTML := out.BodyHTML + bodyText := out.BodyPlainText + bodyRaw := bodyHTML + if bodyRaw == "" { + bodyRaw = bodyText + } + + references := "" + if len(out.References) > 0 { + references = strings.Join(out.References, " ") + } + + // Strip CR and LF from the inherited subject to prevent header injection when + // this value is later passed to emlbuilder.Subject() in reply/forward flows. + // A malicious source email could carry "\r\nBcc: evil@evil.com" in its Subject. + safeSubject := strings.NewReplacer("\r", "", "\n", "").Replace(out.Subject) + + return originalMessage{ + subject: safeSubject, + headFrom: fromEmail, + headFromName: fromName, + headTo: headTo, + replyTo: out.ReplyTo, + replyToSMTPMessageID: out.ReplyToSMTPMessageID, + smtpMessageId: out.SMTPMessageID, + threadId: out.ThreadID, + bodyRaw: bodyRaw, + headDate: headDate, + references: references, + toAddresses: toList, + ccAddresses: ccList, + toAddressesFull: toFullList, + ccAddressesFull: ccFullList, + } +} + +func toForwardSourceAttachments(out normalizedMessageForCompose) []forwardSourceAttachment { + atts := make([]forwardSourceAttachment, 0, len(out.Attachments)) + for _, att := range out.Attachments { + atts = append(atts, forwardSourceAttachment{ + ID: att.ID, + Filename: att.Filename, + ContentType: att.ContentType, + AttachmentType: att.AttachmentType, + DownloadURL: att.DownloadURL, + }) + } + return atts +} + +func toInlineSourceParts(out normalizedMessageForCompose) []inlineSourcePart { + parts := make([]inlineSourcePart, 0, len(out.Images)) + for _, img := range out.Images { + if img.CID == "" { + continue + } + parts = append(parts, inlineSourcePart{ + ID: img.ID, + Filename: img.Filename, + ContentType: img.ContentType, + CID: img.CID, + DownloadURL: img.DownloadURL, + }) + } + return parts +} + +// downloadAttachmentContent fetches the content at downloadURL. +// Lark pre-signed download URLs embed an authcode in the query string and do +// not require an Authorization header, so we never send the Bearer token. +func downloadAttachmentContent(runtime *common.RuntimeContext, downloadURL string) ([]byte, error) { + u, err := url.Parse(downloadURL) + if err != nil { + return nil, fmt.Errorf("invalid attachment download URL: %w", err) + } + if u.Scheme != "https" { + return nil, fmt.Errorf("attachment download URL must use https (got %q)", u.Scheme) + } + if u.Host == "" { + return nil, fmt.Errorf("attachment download URL has no host") + } + + httpClient, err := runtime.Factory.HttpClient() + if err != nil { + return nil, fmt.Errorf("failed to get HTTP client: %w", err) + } + req, err := http.NewRequestWithContext(runtime.Ctx(), http.MethodGet, downloadURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to build attachment download request: %w", err) + } + // Do NOT send Authorization: the download_url is a pre-signed URL with an + // authcode embedded in the query string. Attaching the Bearer token would + // leak it to whatever host the URL points at (SSRF / token exfiltration). + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to download attachment: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("failed to download attachment: HTTP %d", resp.StatusCode) + } + limitedReader := io.LimitReader(resp.Body, int64(MaxAttachmentDownloadBytes)+1) + data, err := io.ReadAll(limitedReader) + if err != nil { + return nil, fmt.Errorf("failed to read attachment content: %w", err) + } + if len(data) > MaxAttachmentDownloadBytes { + return nil, fmt.Errorf("attachment download exceeds %d MB size limit", MaxAttachmentDownloadBytes/1024/1024) + } + return data, nil +} + +// --- internal helpers --- + +func strVal(v interface{}) string { + s, _ := v.(string) + return s +} + +func intVal(v interface{}) int { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + case json.Number: + i, _ := n.Int64() + return int(i) + } + return 0 +} + +func decodeBase64URL(s string) string { + if s == "" { + return "" + } + b, err := base64.URLEncoding.DecodeString(s) + if err != nil { + b, err = base64.RawURLEncoding.DecodeString(s) + if err != nil { + return s + } + } + return string(b) +} + +// decodeBodyFields decodes body_html and body_plain_text from src into dst. +// Fields absent or empty in src are skipped. Both padding and no-padding base64url variants +// are accepted by the underlying decodeBase64URL call. +func decodeBodyFields(src, dst map[string]interface{}) { + for _, field := range []string{"body_html", "body_plain_text"} { + if s := strVal(src[field]); s != "" { + dst[field] = decodeBase64URL(s) + } + } +} + +// ansiEscapeRe matches ANSI CSI escape sequences (ESC '[' ... ). +var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + +// sanitizeForTerminal strips ANSI escape sequences, bare CR characters, and +// dangerous Unicode code points (BiDi overrides, zero-width chars, etc.) to +// prevent terminal injection from untrusted email content. +func sanitizeForTerminal(s string) string { + s = ansiEscapeRe.ReplaceAllString(s, "") + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + if r == '\r' { + continue + } + if common.IsDangerousUnicode(r) { + continue + } + b.WriteRune(r) + } + return b.String() +} + +func toAddressObject(v interface{}) mailAddressOutput { + if m, ok := v.(map[string]interface{}); ok { + return mailAddressOutput{Email: strVal(m["mail_address"]), Name: strVal(m["name"])} + } + return mailAddressOutput{} +} + +func toAddressList(v interface{}) []mailAddressOutput { + list, _ := v.([]interface{}) + out := make([]mailAddressOutput, 0, len(list)) + for _, item := range list { + out = append(out, toAddressObject(item)) + } + return out +} + +func toAddressEmailList(raw []mailAddressOutput) []string { + out := make([]string, 0, len(raw)) + for _, addr := range raw { + email := addr.Email + if email != "" { + out = append(out, email) + } + } + return out +} + +func toStringList(v interface{}) []string { + list, _ := v.([]interface{}) + out := make([]string, 0, len(list)) + for _, item := range list { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} + +func toSecurityLevel(v interface{}) *mailSecurityLevelOutput { + raw, ok := v.(map[string]interface{}) + if !ok || raw == nil { + return nil + } + riskBannerLevel := strVal(raw["risk_banner_level"]) + riskBannerReason := strVal(raw["risk_banner_reason"]) + spamBannerType := strVal(raw["spam_banner_type"]) + return &mailSecurityLevelOutput{ + IsRisk: boolVal(raw["is_risk"]), + RiskBannerLevel: riskBannerLevel, + RiskBannerReason: riskBannerReason, + IsHeaderFromExternal: boolVal(raw["is_header_from_external"]), + ViaDomain: strVal(raw["via_domain"]), + SpamBannerType: spamBannerType, + SpamUserRuleID: strVal(raw["spam_user_rule_id"]), + SpamBannerInfo: strVal(raw["spam_banner_info"]), + } +} + +func boolVal(v interface{}) bool { + b, _ := v.(bool) + return b +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func resolveAttachmentContentType(att map[string]interface{}, filename string) string { + if ct := strVal(att["content_type"]); ct != "" { + return ct + } + if ext := strings.ToLower(filepath.Ext(filename)); ext != "" { + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + } + return "application/octet-stream" +} + +func messageStateText(state int) string { + switch state { + case 1: + return "received" + case 2: + return "sent" + case 3: + return "draft" + default: + return "unknown" + } +} + +func priorityTypeText(priorityType string) string { + switch priorityType { + case "0": + return "unknown" + case "1": + return "high" + case "3": + return "normal" + case "5": + return "low" + default: + return "unknown" + } +} + +// parseNetAddrs converts a comma-separated address string to []net/mail.Address. +// It reuses ParseMailboxList for display-name-aware parsing and deduplicates +// by email address (case-insensitive), preserving the first occurrence. +func parseNetAddrs(raw string) []netmail.Address { + boxes := ParseMailboxList(raw) + seen := make(map[string]bool, len(boxes)) + out := make([]netmail.Address, 0, len(boxes)) + for _, m := range boxes { + key := strings.ToLower(m.Email) + if seen[key] { + continue + } + seen[key] = true + out = append(out, netmail.Address{Name: m.Name, Address: m.Email}) + } + return out +} + +// mergeAddrLists merges two comma-separated address lists, deduplicating by +// email (case-insensitive). Addresses in base come first; addresses in extra +// that already appear in base are silently dropped. +func mergeAddrLists(base, extra string) string { + if extra == "" { + return base + } + if base == "" { + return extra + } + seen := make(map[string]bool) + for _, m := range ParseMailboxList(base) { + seen[strings.ToLower(m.Email)] = true + } + var additions []string + for _, m := range ParseMailboxList(extra) { + lower := strings.ToLower(m.Email) + if seen[lower] { + continue + } + seen[lower] = true + additions = append(additions, m.String()) + } + if len(additions) == 0 { + return base + } + return base + ", " + strings.Join(additions, ", ") +} + +// ---- Compose domain types -------------------------------------------------- + +// originalMessage holds the metadata and body extracted from the original email. +type originalMessage struct { + subject string + headFrom string + headFromName string // display name of sender, for attribution line + headTo string // first recipient (likely current user's email) + replyTo string // Reply-To address; reply/reply-all should prefer this over headFrom + replyToSMTPMessageID string // SMTP Message-ID of the Reply-To target + smtpMessageId string + threadId string + bodyRaw string // raw body from API (may be HTML) + headDate string // Date header, for attribution line + references string // space-separated RFC 2822 References chain from original + toAddresses []string // email-only list, used by reply-all recipient logic + ccAddresses []string // email-only list, used by reply-all recipient logic + toAddressesFull []mailAddressPair // name+email pairs for quote display + ccAddressesFull []mailAddressPair // name+email pairs for quote display +} + +func normalizeMessageID(id string) string { + trimmed := strings.TrimSpace(id) + trimmed = strings.TrimPrefix(trimmed, "<") + trimmed = strings.TrimSuffix(trimmed, ">") + return strings.TrimSpace(trimmed) +} + +func normalizeInlineCID(cid string) string { + trimmed := strings.TrimSpace(cid) + if len(trimmed) >= 4 && strings.EqualFold(trimmed[:4], "cid:") { + trimmed = trimmed[4:] + } + trimmed = strings.TrimPrefix(trimmed, "<") + trimmed = strings.TrimSuffix(trimmed, ">") + return strings.TrimSpace(trimmed) +} + +func addInlineImagesToBuilder(runtime *common.RuntimeContext, bld emlbuilder.Builder, images []inlineSourcePart) (emlbuilder.Builder, error) { + for _, img := range images { + content, err := downloadAttachmentContent(runtime, img.DownloadURL) + if err != nil { + return bld, fmt.Errorf("failed to download inline resource %s: %w", img.Filename, err) + } + cid := normalizeInlineCID(img.CID) + if cid == "" { + continue + } + contentType := img.ContentType + if contentType == "" { + contentType = "application/octet-stream" + } + bld = bld.AddInline(content, contentType, img.Filename, cid) + } + return bld, nil +} + +// InlineSpec represents one inline image entry from the --inline JSON array. +// CID must be a valid RFC 2822 content-id (e.g. a random hex string). +// FilePath is the local path to the image file. +type InlineSpec struct { + CID string `json:"cid"` + FilePath string `json:"file_path"` +} + +// parseInlineSpecs parses the --inline flag value as a JSON array of InlineSpec. +// Returns an empty slice when raw is empty. +func parseInlineSpecs(raw string) ([]InlineSpec, error) { + if strings.TrimSpace(raw) == "" { + return nil, nil + } + var specs []InlineSpec + if err := json.Unmarshal([]byte(raw), &specs); err != nil { + return nil, fmt.Errorf("--inline must be a JSON array, e.g. '[{\"cid\":\"a1b2c3d4e5f6a7b8c9d0\",\"file_path\":\"./banner.png\"}]': %w", err) + } + for i, s := range specs { + if strings.TrimSpace(s.CID) == "" { + return nil, fmt.Errorf("--inline entry %d: \"cid\" must not be empty", i) + } + if strings.TrimSpace(s.FilePath) == "" { + return nil, fmt.Errorf("--inline entry %d: \"file_path\" must not be empty", i) + } + } + return specs, nil +} + +// inlineSpecFilePaths returns the file paths from a slice of InlineSpec, for use in size checks. +func inlineSpecFilePaths(specs []InlineSpec) []string { + if len(specs) == 0 { + return nil + } + paths := make([]string, len(specs)) + for i, s := range specs { + paths[i] = s.FilePath + } + return paths +} + +// checkAttachmentSizeLimit returns an error if the combined attachment count exceeds +// MaxAttachmentCount or the combined size exceeds MaxAttachmentBytes. +// filePaths are read via os.Stat (no full read); extraBytes / extraCount account for +// already-loaded content (e.g. downloaded original attachments in +forward). +func checkAttachmentSizeLimit(filePaths []string, extraBytes int64, extraCount ...int) error { + extra := 0 + for _, c := range extraCount { + extra += c + } + total := extra + len(filePaths) + if total > MaxAttachmentCount { + return fmt.Errorf("attachment count %d exceeds the limit of %d", total, MaxAttachmentCount) + } + totalBytes := extraBytes + for _, p := range filePaths { + safePath, err := validate.SafeInputPath(p) + if err != nil { + return fmt.Errorf("unsafe attachment path %s: %w", p, err) + } + info, err := os.Stat(safePath) + if err != nil { + return fmt.Errorf("failed to stat attachment %s: %w", p, err) + } + totalBytes += info.Size() + } + if totalBytes > MaxAttachmentBytes { + return fmt.Errorf("total attachment size %.1f MB exceeds the 25 MB limit", + float64(totalBytes)/1024/1024) + } + return nil +} + +func validateComposeHasAtLeastOneRecipient(to, cc, bcc string) error { + if strings.TrimSpace(to) == "" && strings.TrimSpace(cc) == "" && strings.TrimSpace(bcc) == "" { + return fmt.Errorf("at least one recipient (--to, --cc, or --bcc) is required") + } + return validateRecipientCount(to, cc, bcc) +} + +// validateRecipientCount checks that the total number of recipients across +// To, CC, and BCC does not exceed MaxRecipientCount. +func validateRecipientCount(to, cc, bcc string) error { + count := len(ParseMailboxList(to)) + len(ParseMailboxList(cc)) + len(ParseMailboxList(bcc)) + if count > MaxRecipientCount { + return fmt.Errorf("total recipient count %d exceeds the limit of %d (To + CC + BCC combined)", count, MaxRecipientCount) + } + return nil +} + +func validateComposeInlineAndAttachments(attachFlag, inlineFlag string, plainText bool, body string) error { + if strings.TrimSpace(inlineFlag) != "" { + if plainText { + return fmt.Errorf("--inline is not supported with --plain-text (inline images require HTML body)") + } + if body != "" && !bodyIsHTML(body) { + return fmt.Errorf("--inline requires an HTML body (the provided body appears to be plain text; add HTML tags or remove --inline)") + } + } + inlineSpecs, err := parseInlineSpecs(inlineFlag) + if err != nil { + return err + } + allFiles := append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...) + if err := checkAttachmentSizeLimit(allFiles, 0); err != nil { + return err + } + return nil +} diff --git a/shortcuts/mail/helpers_test.go b/shortcuts/mail/helpers_test.go new file mode 100644 index 00000000..3b5d3159 --- /dev/null +++ b/shortcuts/mail/helpers_test.go @@ -0,0 +1,901 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/mail/emlbuilder" +) + +func TestDecodeBodyFields(t *testing.T) { + htmlEncoded := base64.URLEncoding.EncodeToString([]byte("

Hello

")) + plainEncoded := base64.RawURLEncoding.EncodeToString([]byte("Hello plain")) + + src := map[string]interface{}{ + "body_html": htmlEncoded, + "body_plain_text": plainEncoded, + "subject": "untouched", + } + dst := map[string]interface{}{} + decodeBodyFields(src, dst) + + if dst["body_html"] != "

Hello

" { + t.Fatalf("body_html not decoded: %#v", dst["body_html"]) + } + if dst["body_plain_text"] != "Hello plain" { + t.Fatalf("body_plain_text not decoded: %#v", dst["body_plain_text"]) + } + if _, ok := dst["subject"]; ok { + t.Fatalf("subject should not be copied by decodeBodyFields") + } + // src must not be modified + if src["body_html"] != htmlEncoded { + t.Fatalf("src was mutated") + } +} + +func TestDecodeBodyFieldsSkipsAbsent(t *testing.T) { + src := map[string]interface{}{"subject": "no body"} + dst := map[string]interface{}{} + decodeBodyFields(src, dst) + if len(dst) != 0 { + t.Fatalf("expected empty dst, got %#v", dst) + } +} + +func TestMessageFieldPolicy(t *testing.T) { + if !shouldExposeRawMessageField("custom_meta") { + t.Fatalf("custom metadata should be auto-passed through") + } + if shouldExposeRawMessageField("body_plain_text") { + t.Fatalf("body_* fields should not be auto-passed through") + } + if !shouldExposeRawMessageField("head_from") { + t.Fatalf("head_from should be auto-passed through") + } + if shouldExposeRawMessageField("attachments") { + t.Fatalf("attachments should be derived, not auto-passed through") + } + if len(derivedMessageFields) == 0 { + t.Fatalf("derivedMessageFields should document derived output fields") + } +} + +func TestToForwardSourceAttachments(t *testing.T) { + out := normalizedMessageForCompose{ + Attachments: []mailAttachmentOutput{ + { + ID: "att1", + Filename: "report.pdf", + ContentType: "application/pdf", + DownloadURL: "https://example.com/att1", + }, + }, + } + + atts := toForwardSourceAttachments(out) + if len(atts) != 1 { + t.Fatalf("expected 1 attachment, got %d", len(atts)) + } + if atts[0].Filename != "report.pdf" { + t.Fatalf("unexpected filename: %s", atts[0].Filename) + } + if atts[0].DownloadURL == "" { + t.Fatalf("expected download_url to be set") + } +} + +// --------------------------------------------------------------------------- +// parseInlineSpecs +// --------------------------------------------------------------------------- + +func TestParseInlineSpecs_Empty(t *testing.T) { + specs, err := parseInlineSpecs("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(specs) != 0 { + t.Fatalf("expected empty slice, got %v", specs) + } +} + +func TestParseInlineSpecs_Whitespace(t *testing.T) { + specs, err := parseInlineSpecs(" ") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(specs) != 0 { + t.Fatalf("expected empty slice for whitespace input, got %v", specs) + } +} + +func TestParseInlineSpecs_Valid(t *testing.T) { + raw := `[{"cid":"YmFubmVyLnBuZw","file_path":"./banner.png"},{"cid":"bG9nby5wbmc","file_path":"/abs/logo.png"}]` + specs, err := parseInlineSpecs(raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(specs) != 2 { + t.Fatalf("expected 2 specs, got %d", len(specs)) + } + if specs[0].CID != "YmFubmVyLnBuZw" { + t.Errorf("specs[0].CID = %q, want YmFubmVyLnBuZw", specs[0].CID) + } + if specs[0].FilePath != "./banner.png" { + t.Errorf("specs[0].FilePath = %q, want ./banner.png", specs[0].FilePath) + } + if specs[1].CID != "bG9nby5wbmc" { + t.Errorf("specs[1].CID = %q, want bG9nby5wbmc", specs[1].CID) + } + if specs[1].FilePath != "/abs/logo.png" { + t.Errorf("specs[1].FilePath = %q, want /abs/logo.png", specs[1].FilePath) + } +} + +func TestParseInlineSpecs_InvalidJSON(t *testing.T) { + _, err := parseInlineSpecs(`not-json`) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestParseInlineSpecs_MissingCID(t *testing.T) { + _, err := parseInlineSpecs(`[{"cid":"","file_path":"./banner.png"}]`) + if err == nil { + t.Fatal("expected error for empty cid, got nil") + } +} + +func TestParseInlineSpecs_MissingFilePath(t *testing.T) { + _, err := parseInlineSpecs(`[{"cid":"YmFubmVyLnBuZw","file_path":""}]`) + if err == nil { + t.Fatal("expected error for empty file_path, got nil") + } +} + +func TestParseInlineSpecs_OldKeyRejected(t *testing.T) { + // "file-path" (kebab) must not be recognised — only "file_path" (snake) is valid. + // The JSON decoder will silently ignore unknown keys, so file_path stays empty → validation error. + _, err := parseInlineSpecs(`[{"cid":"YmFubmVyLnBuZw","file-path":"./banner.png"}]`) + if err == nil { + t.Fatal("expected error when using old kebab-case key \"file-path\", got nil") + } +} + +// --------------------------------------------------------------------------- +// inlineSpecFilePaths +// --------------------------------------------------------------------------- + +func TestInlineSpecFilePaths(t *testing.T) { + specs := []InlineSpec{ + {CID: "cid1", FilePath: "./a.png"}, + {CID: "cid2", FilePath: "/b.jpg"}, + } + paths := inlineSpecFilePaths(specs) + if len(paths) != 2 { + t.Fatalf("expected 2 paths, got %d", len(paths)) + } + if paths[0] != "./a.png" { + t.Errorf("paths[0] = %q, want ./a.png", paths[0]) + } + if paths[1] != "/b.jpg" { + t.Errorf("paths[1] = %q, want /b.jpg", paths[1]) + } +} + +func TestInlineSpecFilePaths_Nil(t *testing.T) { + if paths := inlineSpecFilePaths(nil); paths != nil { + t.Fatalf("expected nil for nil input, got %v", paths) + } +} + +// --------------------------------------------------------------------------- +// validateForwardAttachmentURLs / validateInlineImageURLs +// --------------------------------------------------------------------------- + +func TestValidateForwardAttachmentURLs_MissingDownloadURL(t *testing.T) { + src := composeSourceMessage{ + ForwardAttachments: []forwardSourceAttachment{ + {ID: "att1", Filename: "report.pdf", DownloadURL: "https://example.com/att1"}, + {ID: "att2", Filename: "budget.xlsx", DownloadURL: ""}, // missing + }, + } + err := validateForwardAttachmentURLs(src) + if err == nil { + t.Fatal("expected error when attachment download URL is missing, got nil") + } + if !strings.Contains(err.Error(), "budget.xlsx") { + t.Errorf("error should mention missing attachment filename, got: %v", err) + } +} + +func TestValidateForwardAttachmentURLs_IgnoresInlineImages(t *testing.T) { + src := composeSourceMessage{ + ForwardAttachments: []forwardSourceAttachment{ + {ID: "att1", Filename: "report.pdf", DownloadURL: "https://example.com/att1"}, + }, + InlineImages: []inlineSourcePart{ + {ID: "img1", Filename: "logo.png", CID: "cid1", DownloadURL: ""}, // missing but should NOT cause error + }, + } + if err := validateForwardAttachmentURLs(src); err != nil { + t.Fatalf("inline image missing URL should not affect forward attachment validation: %v", err) + } +} + +func TestValidateForwardAttachmentURLs_AllPresent(t *testing.T) { + src := composeSourceMessage{ + ForwardAttachments: []forwardSourceAttachment{ + {ID: "att1", Filename: "report.pdf", DownloadURL: "https://example.com/att1"}, + }, + InlineImages: []inlineSourcePart{ + {ID: "img1", Filename: "logo.png", CID: "cid1", DownloadURL: "https://example.com/img1"}, + }, + } + if err := validateForwardAttachmentURLs(src); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateInlineImageURLs_MissingDownloadURL(t *testing.T) { + src := composeSourceMessage{ + ForwardAttachments: []forwardSourceAttachment{ + {ID: "att1", Filename: "report.pdf", DownloadURL: ""}, // missing but should NOT cause error + }, + InlineImages: []inlineSourcePart{ + {ID: "img1", Filename: "banner.png", CID: "cid1", DownloadURL: ""}, // missing + }, + } + err := validateInlineImageURLs(src) + if err == nil { + t.Fatal("expected error when inline image download URL is missing, got nil") + } + if !strings.Contains(err.Error(), "banner.png") { + t.Errorf("error should mention missing inline image filename, got: %v", err) + } +} + +func TestValidateInlineImageURLs_IgnoresAttachments(t *testing.T) { + // Inline images are fine; attachments have missing URLs but should NOT be checked. + src := composeSourceMessage{ + ForwardAttachments: []forwardSourceAttachment{ + {ID: "att1", Filename: "report.pdf", DownloadURL: ""}, // missing — irrelevant for this check + }, + InlineImages: []inlineSourcePart{ + {ID: "img1", Filename: "logo.png", CID: "cid1", DownloadURL: "https://example.com/img1"}, + }, + } + if err := validateInlineImageURLs(src); err != nil { + t.Fatalf("unexpected error — attachment missing URL should not affect inline-only validation: %v", err) + } +} + +func TestToForwardSourceAttachments_PreservesMissingURL(t *testing.T) { + out := normalizedMessageForCompose{ + Attachments: []mailAttachmentOutput{ + {ID: "att1", Filename: "ok.pdf", DownloadURL: "https://example.com/ok"}, + {ID: "att2", Filename: "broken.pdf", DownloadURL: ""}, + }, + } + atts := toForwardSourceAttachments(out) + if len(atts) != 2 { + t.Fatalf("expected 2 attachments (including missing URL), got %d", len(atts)) + } +} + +func TestToInlineSourceParts_PreservesMissingURL(t *testing.T) { + out := normalizedMessageForCompose{ + Images: []mailImageOutput{ + {ID: "img1", Filename: "ok.png", CID: "cid1", DownloadURL: "https://example.com/ok"}, + {ID: "img2", Filename: "broken.png", CID: "cid2", DownloadURL: ""}, + }, + } + parts := toInlineSourceParts(out) + if len(parts) != 2 { + t.Fatalf("expected 2 inline parts (including missing URL), got %d", len(parts)) + } +} + +// --- downloadAttachmentContent security tests --- + +// newDownloadRuntime builds a minimal RuntimeContext that uses the given +// *http.Client for outbound requests. +func newDownloadRuntime(t *testing.T, client *http.Client) *common.RuntimeContext { + t.Helper() + f := &cmdutil.Factory{ + HttpClient: func() (*http.Client, error) { return client, nil }, + } + rt := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{}, nil) + rt.Factory = f + return rt +} + +func TestDownloadAttachmentContent_RejectsHTTP(t *testing.T) { + rt := newDownloadRuntime(t, &http.Client{}) + _, err := downloadAttachmentContent(rt, "http://evil.example.com/file") + if err == nil || !strings.Contains(err.Error(), "https") { + t.Errorf("expected https-required error, got: %v", err) + } +} + +func TestDownloadAttachmentContent_RejectsFileScheme(t *testing.T) { + rt := newDownloadRuntime(t, &http.Client{}) + _, err := downloadAttachmentContent(rt, "file:///etc/passwd") + if err == nil || !strings.Contains(err.Error(), "https") { + t.Errorf("expected https-required error, got: %v", err) + } +} + +func TestDownloadAttachmentContent_RejectsEmptyHost(t *testing.T) { + rt := newDownloadRuntime(t, &http.Client{}) + _, err := downloadAttachmentContent(rt, "https:///no-host") + if err == nil || !strings.Contains(err.Error(), "host") { + t.Errorf("expected no-host error, got: %v", err) + } +} + +func TestDownloadAttachmentContent_NoAuthorizationHeader(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "" { + http.Error(w, "unexpected Authorization header", http.StatusForbidden) + return + } + fmt.Fprint(w, "attachment data") + })) + defer srv.Close() + + rt := newDownloadRuntime(t, srv.Client()) + data, err := downloadAttachmentContent(rt, srv.URL+"/file?code=presigned") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != "attachment data" { + t.Errorf("unexpected content: %q", data) + } +} + +// --------------------------------------------------------------------------- +// newOutputRuntime — helper for tests that call runtime.Out / runtime.IO() +// --------------------------------------------------------------------------- + +func newOutputRuntime(t *testing.T) (*common.RuntimeContext, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + f := &cmdutil.Factory{ + IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}, + } + rt := common.TestNewRuntimeContext(&cobra.Command{}, nil) + rt.Factory = f + return rt, stdout, stderr +} + +// --------------------------------------------------------------------------- +// printMessageOutputSchema +// --------------------------------------------------------------------------- + +func TestPrintMessageOutputSchema(t *testing.T) { + rt, stdout, _ := newOutputRuntime(t) + printMessageOutputSchema(rt) + out := stdout.String() + // Verify key fields from the schema are present + for _, key := range []string{ + "body_plain_text", "body_html", "attachments", "head_from", + "bcc", "date", "smtp_message_id", "in_reply_to", "references", + "internal_date", "message_state", "message_state_text", + "folder_id", "label_ids", "priority_type", "priority_type_text", + "security_level", "draft_id", "reply_to", "reply_to_smtp_message_id", + "body_preview", "thread_id", "message_count", + "date_formatted", + } { + if !strings.Contains(out, key) { + t.Errorf("printMessageOutputSchema output missing key %q", key) + } + } +} + +// --------------------------------------------------------------------------- +// printWatchOutputSchema +// --------------------------------------------------------------------------- + +func TestPrintWatchOutputSchema(t *testing.T) { + rt, stdout, _ := newOutputRuntime(t) + printWatchOutputSchema(rt) + out := stdout.String() + for _, key := range []string{ + "event", "minimal", "metadata", "plain_text_full", "full", + "event_id", "message_id", + "body_plain_text", "body_html", + "attachments", + } { + if !strings.Contains(out, key) { + t.Errorf("printWatchOutputSchema output missing key %q", key) + } + } +} + +// --------------------------------------------------------------------------- +// hintMarkAsRead — sanitizeForTerminal integration +// --------------------------------------------------------------------------- + +func TestHintMarkAsRead(t *testing.T) { + rt, _, stderr := newOutputRuntime(t) + // Inject ANSI escape + message ID to verify sanitization + hintMarkAsRead(rt, "me", "msg-\x1b[31m123") + out := stderr.String() + if strings.Contains(out, "\x1b[") { + t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out) + } + if !strings.Contains(out, "msg-123") { + t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out) + } +} + +// --------------------------------------------------------------------------- +// intVal — json.Number +// --------------------------------------------------------------------------- + +func TestIntVal_JsonNumber(t *testing.T) { + n := json.Number("42") + got := intVal(n) + if got != 42 { + t.Errorf("intVal(json.Number(\"42\")) = %d, want 42", got) + } +} + +func TestIntVal_JsonNumberInvalid(t *testing.T) { + n := json.Number("not-a-number") + got := intVal(n) + if got != 0 { + t.Errorf("intVal(json.Number(\"not-a-number\")) = %d, want 0", got) + } +} + +// --------------------------------------------------------------------------- +// toOriginalMessageForCompose +// --------------------------------------------------------------------------- + +func TestToOriginalMessageForCompose(t *testing.T) { + out := normalizedMessageForCompose{ + Subject: "Test Subject\r\nBcc: evil@evil.com", + From: mailAddressOutput{Email: "alice@example.com", Name: "Alice"}, + To: []mailAddressOutput{{Email: "bob@example.com", Name: "Bob"}}, + CC: []mailAddressOutput{{Email: "carol@example.com", Name: "Carol"}}, + SMTPMessageID: "", + ThreadID: "thread-1", + BodyHTML: "

Hello

", + BodyPlainText: "Hello", + InternalDate: "1711111111000", + References: []string{""}, + ReplyTo: "replyto@example.com", + } + + orig := toOriginalMessageForCompose(out) + + // Subject injection should be stripped + if strings.Contains(orig.subject, "\r") || strings.Contains(orig.subject, "\n") { + t.Errorf("subject should have CR/LF stripped, got: %q", orig.subject) + } + if !strings.Contains(orig.subject, "Test Subject") { + t.Errorf("subject should still contain original text, got: %q", orig.subject) + } + + if orig.headFrom != "alice@example.com" { + t.Errorf("headFrom = %q, want alice@example.com", orig.headFrom) + } + if orig.headFromName != "Alice" { + t.Errorf("headFromName = %q, want Alice", orig.headFromName) + } + if orig.headTo != "bob@example.com" { + t.Errorf("headTo = %q, want bob@example.com", orig.headTo) + } + if orig.replyTo != "replyto@example.com" { + t.Errorf("replyTo = %q, want replyto@example.com", orig.replyTo) + } + if orig.smtpMessageId != "" { + t.Errorf("smtpMessageId = %q", orig.smtpMessageId) + } + if orig.threadId != "thread-1" { + t.Errorf("threadId = %q", orig.threadId) + } + if orig.bodyRaw != "

Hello

" { + t.Errorf("bodyRaw should prefer HTML, got: %q", orig.bodyRaw) + } + if orig.headDate == "" { + t.Error("headDate should be set from InternalDate") + } + if orig.references != "" { + t.Errorf("references = %q", orig.references) + } + if len(orig.toAddresses) != 1 || orig.toAddresses[0] != "bob@example.com" { + t.Errorf("toAddresses = %v", orig.toAddresses) + } + if len(orig.ccAddresses) != 1 || orig.ccAddresses[0] != "carol@example.com" { + t.Errorf("ccAddresses = %v", orig.ccAddresses) + } + if len(orig.toAddressesFull) != 1 { + t.Errorf("toAddressesFull = %v", orig.toAddressesFull) + } + if len(orig.ccAddressesFull) != 1 { + t.Errorf("ccAddressesFull = %v", orig.ccAddressesFull) + } +} + +func TestToOriginalMessageForCompose_NoHTML(t *testing.T) { + out := normalizedMessageForCompose{ + Subject: "Plain email", + From: mailAddressOutput{Email: "alice@example.com"}, + BodyPlainText: "Just plain text", + } + orig := toOriginalMessageForCompose(out) + if orig.bodyRaw != "Just plain text" { + t.Errorf("bodyRaw should fall back to plaintext, got: %q", orig.bodyRaw) + } + if orig.headTo != "" { + t.Errorf("headTo should be empty when To list is empty, got: %q", orig.headTo) + } +} + +func TestToOriginalMessageForCompose_EmptyReferences(t *testing.T) { + out := normalizedMessageForCompose{ + From: mailAddressOutput{Email: "alice@example.com"}, + References: nil, + } + orig := toOriginalMessageForCompose(out) + if orig.references != "" { + t.Errorf("references should be empty, got: %q", orig.references) + } +} + +// --------------------------------------------------------------------------- +// checkAttachmentSizeLimit +// --------------------------------------------------------------------------- + +func TestCheckAttachmentSizeLimit_NoFiles(t *testing.T) { + if err := checkAttachmentSizeLimit(nil, 0); err != nil { + t.Fatalf("unexpected error for empty: %v", err) + } +} + +func TestCheckAttachmentSizeLimit_CountExceeded(t *testing.T) { + err := checkAttachmentSizeLimit(nil, 0, MaxAttachmentCount+1) + if err == nil { + t.Fatal("expected error for count exceeded") + } + if !strings.Contains(err.Error(), "count") { + t.Errorf("error should mention count: %v", err) + } +} + +func TestCheckAttachmentSizeLimit_SizeExceeded(t *testing.T) { + // extraBytes alone exceeds the limit + err := checkAttachmentSizeLimit(nil, MaxAttachmentBytes+1) + if err == nil { + t.Fatal("expected error for size exceeded") + } + if !strings.Contains(err.Error(), "25 MB") { + t.Errorf("error should mention 25 MB limit: %v", err) + } +} + +func TestCheckAttachmentSizeLimit_WithFiles(t *testing.T) { + // Create a small temp file to exercise the file stat path + dir := t.TempDir() + f := filepath.Join(dir, "small.txt") + if err := os.WriteFile(f, []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + // Use the temp dir as the CWD so the relative path works + oldWd, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(oldWd) + + err := checkAttachmentSizeLimit([]string{"./small.txt"}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// downloadAttachmentContent — size limit enforcement +// --------------------------------------------------------------------------- + +func TestDownloadAttachmentContent_HTTP4xx(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + rt := newDownloadRuntime(t, srv.Client()) + _, err := downloadAttachmentContent(rt, srv.URL+"/missing") + if err == nil || !strings.Contains(err.Error(), "HTTP 404") { + t.Errorf("expected HTTP 404 error, got: %v", err) + } +} + +func TestDownloadAttachmentContent_SizeLimit(t *testing.T) { + // Return a response that claims to be larger than MaxAttachmentDownloadBytes + // We can't actually write 35MB in a test, but we can test the limit logic + // by creating a server that returns slightly more than the limit. + // For efficiency, just verify the error message pattern with a small payload + // and a temporarily reduced limit is not feasible. Instead test the boundary. + bigPayload := strings.Repeat("x", MaxAttachmentDownloadBytes+1) + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, bigPayload) + })) + defer srv.Close() + + rt := newDownloadRuntime(t, srv.Client()) + _, err := downloadAttachmentContent(rt, srv.URL+"/big") + if err == nil || !strings.Contains(err.Error(), "size limit") { + t.Errorf("expected size limit error, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// buildReplyAllRecipients — no-mutation of excluded map (tests the copy fix) +// --------------------------------------------------------------------------- + +func TestBuildReplyAllRecipients_DoesNotMutateExcluded(t *testing.T) { + excluded := map[string]bool{"blocked@example.com": true} + originalLen := len(excluded) + buildReplyAllRecipients("alice@example.com", nil, nil, "me@example.com", excluded, false) + if len(excluded) != originalLen { + t.Errorf("excluded map was mutated: had %d entries, now has %d", originalLen, len(excluded)) + } +} + +// --------------------------------------------------------------------------- +// addInlineImagesToBuilder — with empty CID skip +// --------------------------------------------------------------------------- + +func TestAddInlineImagesToBuilder_EmptyCIDSkipped(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "imagedata") + })) + defer srv.Close() + + rt := newDownloadRuntime(t, srv.Client()) + bld := emlbuilder.New().TextBody([]byte("test")) + images := []inlineSourcePart{ + {ID: "img1", Filename: "logo.png", ContentType: "image/png", CID: "", DownloadURL: srv.URL + "/img1"}, + } + _, err := addInlineImagesToBuilder(rt, bld, images) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAddInlineImagesToBuilder_Success(t *testing.T) { + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "imagedata") + })) + defer srv.Close() + + rt := newDownloadRuntime(t, srv.Client()) + bld := emlbuilder.New(). + From("Test", "test@example.com"). + To("Recipient", "to@example.com"). + Subject("test"). + HTMLBody([]byte("")) + images := []inlineSourcePart{ + {ID: "img1", Filename: "banner.png", ContentType: "image/png", CID: "cid:banner", DownloadURL: srv.URL + "/img1"}, + } + result, err := addInlineImagesToBuilder(rt, bld, images) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + raw, err := result.BuildBase64URL() + if err != nil { + t.Fatalf("failed to build EML: %v", err) + } + if raw == "" { + t.Error("expected non-empty EML output") + } +} + +// --------------------------------------------------------------------------- +// normalizeInlineCID +// --------------------------------------------------------------------------- + +func TestNormalizeInlineCID(t *testing.T) { + tests := []struct { + input, want string + }{ + {"cid:banner", "banner"}, + {"CID:banner", "banner"}, + {"", "banner"}, + {"cid:", "banner"}, + {" cid: ", "banner"}, + {"plain", "plain"}, + {"", ""}, + } + for _, tt := range tests { + got := normalizeInlineCID(tt.input) + if got != tt.want { + t.Errorf("normalizeInlineCID(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestResolveComposeMailboxID(t *testing.T) { + tests := []struct { + name string + from string + want string + }{ + {"default", "", "me"}, + {"explicit from", "shared@example.com", "shared@example.com"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("from", "", "") + if tt.from != "" { + _ = cmd.Flags().Set("from", tt.from) + } + rt := &common.RuntimeContext{Cmd: cmd} + if got := resolveComposeMailboxID(rt); got != tt.want { + t.Errorf("resolveComposeMailboxID() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseNetAddrs_Dedup(t *testing.T) { + tests := []struct { + name string + input string + want []string // expected email addresses in order + }{ + {"no duplicates", "a@x.com, b@x.com", []string{"a@x.com", "b@x.com"}}, + {"exact duplicate", "a@x.com, a@x.com", []string{"a@x.com"}}, + {"case-insensitive duplicate", "Alice@X.COM, alice@x.com", []string{"Alice@X.COM"}}, + {"mixed with names", "Alice , Bob , a@x.com", []string{"a@x.com", "b@x.com"}}, + {"triple duplicate", "a@x.com, a@x.com, a@x.com", []string{"a@x.com"}}, + {"empty", "", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseNetAddrs(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("parseNetAddrs(%q) returned %d addrs, want %d: %v", tt.input, len(got), len(tt.want), got) + } + for i, addr := range got { + if addr.Address != tt.want[i] { + t.Errorf("parseNetAddrs(%q)[%d].Address = %q, want %q", tt.input, i, addr.Address, tt.want[i]) + } + } + }) + } + + // Verify dedup is per-field only, NOT cross-field: separate calls must + // maintain independent seen sets so the same address can appear in both + // To and CC. + t.Run("no cross-field dedup", func(t *testing.T) { + shared := "overlap@x.com" + toAddrs := parseNetAddrs(shared) + ccAddrs := parseNetAddrs(shared + ", other@x.com") + if len(toAddrs) != 1 || toAddrs[0].Address != shared { + t.Fatalf("to: got %v", toAddrs) + } + if len(ccAddrs) != 2 { + t.Fatalf("cc should have 2 addrs (no cross-field dedup), got %v", ccAddrs) + } + if ccAddrs[0].Address != shared { + t.Errorf("cc[0] = %q, want %q", ccAddrs[0].Address, shared) + } + }) +} + +// --------------------------------------------------------------------------- +// validateRecipientCount +// --------------------------------------------------------------------------- + +func TestValidateRecipientCount(t *testing.T) { + t.Run("under limit", func(t *testing.T) { + err := validateRecipientCount("a@x.com, b@x.com", "c@x.com", "d@x.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("empty fields", func(t *testing.T) { + err := validateRecipientCount("", "", "") + if err != nil { + t.Fatalf("unexpected error for empty fields: %v", err) + } + }) + + t.Run("exactly at limit", func(t *testing.T) { + // Build a list of exactly MaxRecipientCount addresses + addrs := make([]string, MaxRecipientCount) + for i := range addrs { + addrs[i] = fmt.Sprintf("user%d@example.com", i) + } + all := strings.Join(addrs, ",") + err := validateRecipientCount(all, "", "") + if err != nil { + t.Fatalf("should accept exactly %d recipients, got error: %v", MaxRecipientCount, err) + } + }) + + t.Run("exceeds limit", func(t *testing.T) { + addrs := make([]string, MaxRecipientCount+1) + for i := range addrs { + addrs[i] = fmt.Sprintf("user%d@example.com", i) + } + all := strings.Join(addrs, ",") + err := validateRecipientCount(all, "", "") + if err == nil { + t.Fatal("expected error for exceeding recipient limit") + } + if !strings.Contains(err.Error(), "exceeds the limit") { + t.Fatalf("unexpected error message: %v", err) + } + }) + + t.Run("combined across fields", func(t *testing.T) { + // Split across To, CC, BCC to exceed limit + half := MaxRecipientCount / 2 + toAddrs := make([]string, half) + for i := range toAddrs { + toAddrs[i] = fmt.Sprintf("to%d@example.com", i) + } + ccAddrs := make([]string, half) + for i := range ccAddrs { + ccAddrs[i] = fmt.Sprintf("cc%d@example.com", i) + } + // This puts us at MaxRecipientCount, add 1 BCC to exceed + err := validateRecipientCount( + strings.Join(toAddrs, ","), + strings.Join(ccAddrs, ","), + "bcc-extra@example.com", + ) + if err == nil { + t.Fatal("expected error when To+CC+BCC exceeds limit") + } + }) + + t.Run("deduplication within field", func(t *testing.T) { + // ParseMailboxList deduplicates, so duplicates should not inflate count + err := validateRecipientCount("a@x.com, a@x.com, a@x.com", "", "") + if err != nil { + t.Fatalf("duplicates should be deduplicated, got error: %v", err) + } + }) +} + +func TestValidateComposeHasAtLeastOneRecipient_AlsoChecksCount(t *testing.T) { + // Verify that validateComposeHasAtLeastOneRecipient also enforces the count limit + addrs := make([]string, MaxRecipientCount+1) + for i := range addrs { + addrs[i] = fmt.Sprintf("user%d@example.com", i) + } + all := strings.Join(addrs, ",") + err := validateComposeHasAtLeastOneRecipient(all, "", "") + if err == nil { + t.Fatal("expected error for exceeding recipient limit via validateComposeHasAtLeastOneRecipient") + } + if !strings.Contains(err.Error(), "exceeds the limit") { + t.Fatalf("unexpected error message: %v", err) + } +} diff --git a/shortcuts/mail/limits.go b/shortcuts/mail/limits.go new file mode 100644 index 00000000..57ac1077 --- /dev/null +++ b/shortcuts/mail/limits.go @@ -0,0 +1,26 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +// Mail composition limits enforced before sending. +const ( + // MaxAttachmentCount is the maximum number of attachments (including original + // attachments carried over in +forward) allowed per message. + MaxAttachmentCount = 250 + + // MaxAttachmentBytes is the maximum combined size of all attachments in bytes. + // Note: the overall EML size limit (emlbuilder.MaxEMLSize) is enforced separately. + MaxAttachmentBytes = 25 * 1024 * 1024 // 25 MB + + // MaxAttachmentDownloadBytes is the safety limit for downloading a single + // attachment. This is larger than MaxAttachmentBytes (which governs outgoing + // composition) to allow for received attachments that exceed the send-side + // limit. The purpose is to prevent unbounded memory allocation. + MaxAttachmentDownloadBytes = 35 * 1024 * 1024 // 35 MB + + // MaxRecipientCount is the maximum total number of recipients (To + CC + BCC + // combined) allowed per message. This is a defence-in-depth measure to prevent + // abuse such as mass spam or mail bombing via the CLI. + MaxRecipientCount = 500 +) diff --git a/shortcuts/mail/mail_draft_create.go b/shortcuts/mail/mail_draft_create.go new file mode 100644 index 00000000..8b932795 --- /dev/null +++ b/shortcuts/mail/mail_draft_create.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" + "github.com/larksuite/cli/shortcuts/mail/emlbuilder" +) + +type draftCreateInput struct { + To string + Subject string + Body string + From string + CC string + BCC string + Attach string + Inline string + PlainText bool +} + +var MailDraftCreate = common.Shortcut{ + Service: "mail", + Command: "+draft-create", + Description: "Create a brand-new mail draft from scratch (NOT for reply or forward). For reply drafts use +reply; for forward drafts use +forward. Only use +draft-create when composing a new email with no parent message.", + Risk: "write", + Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "to", Desc: "Optional. Full To recipient list. Separate multiple addresses with commas. Display-name format is supported. When omitted, the draft is created without recipients (they can be added later via +draft-edit)."}, + {Name: "subject", Desc: "Required. Final draft subject. Pass the full subject you want to appear in the draft.", Required: true}, + {Name: "body", Desc: "Required. Full email body. Prefer HTML for rich formatting (bold, lists, links); plain text is also supported. Body type is auto-detected. Use --plain-text to force plain-text mode.", Required: true}, + {Name: "from", Desc: "Optional. Sender email address; also selects the mailbox to create the draft in. If omitted, the current signed-in user's primary mailbox address is used."}, + {Name: "cc", Desc: "Optional. Full Cc recipient list. Separate multiple addresses with commas. Display-name format is supported."}, + {Name: "bcc", Desc: "Optional. Full Bcc recipient list. Separate multiple addresses with commas. Display-name format is supported."}, + {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."}, + {Name: "attach", Desc: "Optional. Regular attachment file paths. Separate multiple paths with commas. Each path must point to a readable local file."}, + {Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. Cannot be used with --plain-text. CID images are embedded via in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + input, err := parseDraftCreateInput(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + mailboxID := resolveComposeMailboxID(runtime) + return common.NewDryRunAPI(). + Desc("Create a new empty draft without sending it. The command first reads the current mailbox profile to determine the default sender when `--from` is omitted, then builds a complete EML from `to/subject/body` plus any optional cc/bcc/attachment/inline inputs, and finally calls drafts.create. `--body` content type is auto-detected (HTML or plain text); use `--plain-text` to force plain-text mode. For inline images, CIDs can be any unique strings, e.g. random hex. Use the dedicated reply or forward shortcuts for reply-style drafts instead of adding reply-thread headers here."). + GET(mailboxPath(mailboxID, "profile")). + POST(mailboxPath(mailboxID, "drafts")). + Body(map[string]interface{}{ + "raw": "", + "_preview": map[string]interface{}{ + "to": input.To, + "subject": input.Subject, + }, + }) + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("subject")) == "" { + return output.ErrValidation("--subject is required; pass the final email subject") + } + if strings.TrimSpace(runtime.Str("body")) == "" { + return output.ErrValidation("--body is required; pass the full email body") + } + if err := validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil { + return err + } + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + input, err := parseDraftCreateInput(runtime) + if err != nil { + return err + } + rawEML, err := buildRawEMLForDraftCreate(runtime, input) + if err != nil { + return err + } + mailboxID := resolveComposeMailboxID(runtime) + draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) + if err != nil { + return fmt.Errorf("create draft failed: %w", err) + } + out := map[string]interface{}{"draft_id": draftID} + runtime.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintln(w, "Draft created.") + fmt.Fprintf(w, "draft_id: %s\n", draftID) + }) + return nil + }, +} + +func parseDraftCreateInput(runtime *common.RuntimeContext) (draftCreateInput, error) { + input := draftCreateInput{ + To: runtime.Str("to"), + Subject: runtime.Str("subject"), + Body: runtime.Str("body"), + From: runtime.Str("from"), + CC: runtime.Str("cc"), + BCC: runtime.Str("bcc"), + Attach: runtime.Str("attach"), + Inline: runtime.Str("inline"), + PlainText: runtime.Bool("plain-text"), + } + if strings.TrimSpace(input.Subject) == "" { + return input, output.ErrValidation("--subject is required; pass the final email subject") + } + if strings.TrimSpace(input.Body) == "" { + return input, output.ErrValidation("--body is required; pass the full email body") + } + return input, nil +} + +func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput) (string, error) { + senderEmail := input.From + if senderEmail == "" { + senderEmail = fetchCurrentUserEmail(runtime) + if senderEmail == "" { + return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly") + } + } + + if err := validateRecipientCount(input.To, input.CC, input.BCC); err != nil { + return "", err + } + + bld := emlbuilder.New(). + AllowNoRecipients(). + Subject(input.Subject) + if strings.TrimSpace(input.To) != "" { + bld = bld.ToAddrs(parseNetAddrs(input.To)) + } + if senderEmail != "" { + bld = bld.From("", senderEmail) + } + if input.CC != "" { + bld = bld.CCAddrs(parseNetAddrs(input.CC)) + } + if input.BCC != "" { + bld = bld.BCCAddrs(parseNetAddrs(input.BCC)) + } + if input.PlainText { + bld = bld.TextBody([]byte(input.Body)) + } else if bodyIsHTML(input.Body) { + bld = bld.HTMLBody([]byte(input.Body)) + } else { + bld = bld.TextBody([]byte(input.Body)) + } + inlineSpecs, err := parseInlineSpecs(input.Inline) + if err != nil { + return "", output.ErrValidation("%v", err) + } + for _, path := range splitByComma(input.Attach) { + bld = bld.AddFileAttachment(path) + } + for _, spec := range inlineSpecs { + bld = bld.AddFileInline(spec.FilePath, spec.CID) + } + rawEML, err := bld.BuildBase64URL() + if err != nil { + return "", output.ErrValidation("build EML failed: %v", err) + } + return rawEML, nil +} diff --git a/shortcuts/mail/mail_draft_edit.go b/shortcuts/mail/mail_draft_edit.go new file mode 100644 index 00000000..b28d69d1 --- /dev/null +++ b/shortcuts/mail/mail_draft_edit.go @@ -0,0 +1,378 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" +) + +var MailDraftEdit = common.Shortcut{ + Service: "mail", + Command: "+draft-edit", + Description: "Use when updating an existing mail draft without sending it. Prefer this shortcut over calling raw drafts.get or drafts.update directly, because it performs draft-safe MIME read/patch/write editing while preserving unchanged structure, attachments, and headers where possible.", + Risk: "write", + Scopes: []string{"mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "from", Default: "me", Desc: "Mailbox email address containing the draft (default: me)"}, + {Name: "draft-id", Desc: "Target draft ID. Required for real edits. It can be omitted only when using the --print-patch-template flag by itself."}, + {Name: "set-subject", Desc: "Replace the subject with this final value. Use this for full subject replacement, not for appending a fragment to the existing subject."}, + {Name: "set-to", Desc: "Replace the entire To recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, + {Name: "set-cc", Desc: "Replace the entire Cc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, + {Name: "set-bcc", Desc: "Replace the entire Bcc recipient list with the addresses provided here. Separate multiple addresses with commas. Display-name format is supported."}, + {Name: "patch-file", Desc: "Edit entry point for body edits, incremental recipient changes, header edits, attachment changes, or inline-image changes. All body edits MUST go through --patch-file. Two body ops: set_body (full replacement including quote) and set_reply_body (replaces only user-authored content, auto-preserves quote block). Run --inspect first to check has_quoted_content, then --print-patch-template for the JSON structure."}, + {Name: "print-patch-template", Type: "bool", Desc: "Print the JSON template and supported operations for the --patch-file flag. Recommended first step before generating a patch file. No draft read or write is performed."}, + {Name: "inspect", Type: "bool", Desc: "Inspect the draft without modifying it. Returns the draft projection including subject, recipients, body summary, has_quoted_content (whether the draft contains a reply/forward quote block), attachments_summary (with part_id and cid for each attachment), and inline_summary. Run this BEFORE editing body to check has_quoted_content: if true, use set_reply_body in --patch-file to preserve the quote; if false, use set_body."}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + if runtime.Bool("print-patch-template") { + return common.NewDryRunAPI(). + Set("mode", "print-patch-template"). + Set("template", buildDraftEditPatchTemplate()) + } + draftID := runtime.Str("draft-id") + if draftID == "" { + return common.NewDryRunAPI().Set("error", "--draft-id is required for real draft edits; only --print-patch-template can be used without a draft id") + } + mailboxID := resolveComposeMailboxID(runtime) + if runtime.Bool("inspect") { + return common.NewDryRunAPI(). + Desc("Inspect a draft without modifying it: fetch the raw EML, parse it into MIME structure, and return the projection (subject, recipients, body, attachments_summary, inline_summary). No write is performed."). + GET(mailboxPath(mailboxID, "drafts", draftID)). + Params(map[string]interface{}{"format": "raw"}) + } + patch, err := buildDraftEditPatch(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + Desc("Edit an existing draft without sending it: first call drafts.get(format=raw) to fetch the current EML, parse it into MIME structure, apply either direct flags or the typed patch from patch-file, re-serialize the updated draft, and then call drafts.update. This is a minimal-edit pipeline rather than a full rebuild, so unchanged headers, attachments, and MIME subtrees are preserved where possible. Body edits must go through --patch-file using set_body or set_reply_body ops. It also has no optimistic locking, so concurrent edits to the same draft are last-write-wins."). + GET(mailboxPath(mailboxID, "drafts", draftID)). + Params(map[string]interface{}{"format": "raw"}). + PUT(mailboxPath(mailboxID, "drafts", draftID)). + Body(map[string]interface{}{ + "raw": "", + "_patch": patch.Summary(), + "_notice": "This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.", + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Bool("print-patch-template") { + runtime.Out(buildDraftEditPatchTemplate(), nil) + return nil + } + draftID := runtime.Str("draft-id") + if draftID == "" { + return output.ErrValidation("--draft-id is required for real draft edits; if you only need a patch template, run with --print-patch-template") + } + mailboxID := resolveComposeMailboxID(runtime) + if runtime.Bool("inspect") { + return executeDraftInspect(runtime, mailboxID, draftID) + } + patch, err := buildDraftEditPatch(runtime) + if err != nil { + return err + } + rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID) + if err != nil { + return fmt.Errorf("read draft raw EML failed: %w", err) + } + snapshot, err := draftpkg.Parse(rawDraft) + if err != nil { + return output.ErrValidation("parse draft raw EML failed: %v", err) + } + if err := draftpkg.Apply(snapshot, patch); err != nil { + return output.ErrValidation("apply draft patch failed: %v", err) + } + serialized, err := draftpkg.Serialize(snapshot) + if err != nil { + return output.ErrValidation("serialize draft failed: %v", err) + } + if err := draftpkg.UpdateWithRaw(runtime, mailboxID, draftID, serialized); err != nil { + return fmt.Errorf("update draft failed: %w", err) + } + projection := draftpkg.Project(snapshot) + out := map[string]interface{}{ + "draft_id": draftID, + "warning": "This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.", + "projection": projection, + } + runtime.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintln(w, "Draft updated.") + fmt.Fprintf(w, "draft_id: %s\n", draftID) + if projection.Subject != "" { + fmt.Fprintf(w, "subject: %s\n", sanitizeForTerminal(projection.Subject)) + } + if recipients := prettyDraftAddresses(projection.To); recipients != "" { + fmt.Fprintf(w, "to: %s\n", sanitizeForTerminal(recipients)) + } + if projection.BodyText != "" { + fmt.Fprintf(w, "body_text: %s\n", sanitizeForTerminal(projection.BodyText)) + } + if projection.BodyHTMLSummary != "" { + fmt.Fprintf(w, "body_html_summary: %s\n", sanitizeForTerminal(projection.BodyHTMLSummary)) + } + if len(projection.AttachmentsSummary) > 0 { + fmt.Fprintf(w, "attachments: %d\n", len(projection.AttachmentsSummary)) + } + if len(projection.InlineSummary) > 0 { + fmt.Fprintf(w, "inline_parts: %d\n", len(projection.InlineSummary)) + } + if len(projection.Warnings) > 0 { + fmt.Fprintf(w, "warnings: %s\n", sanitizeForTerminal(strings.Join(projection.Warnings, "; "))) + } + fmt.Fprintln(w, "warning: This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.") + }) + return nil + }, +} + +func executeDraftInspect(runtime *common.RuntimeContext, mailboxID, draftID string) error { + rawDraft, err := draftpkg.GetRaw(runtime, mailboxID, draftID) + if err != nil { + return fmt.Errorf("read draft raw EML failed: %w", err) + } + snapshot, err := draftpkg.Parse(rawDraft) + if err != nil { + return output.ErrValidation("parse draft raw EML failed: %v", err) + } + projection := draftpkg.Project(snapshot) + out := map[string]interface{}{ + "draft_id": draftID, + "projection": projection, + } + runtime.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintln(w, "Draft inspection (read-only, no changes applied).") + fmt.Fprintf(w, "draft_id: %s\n", draftID) + if projection.Subject != "" { + fmt.Fprintf(w, "subject: %s\n", sanitizeForTerminal(projection.Subject)) + } + if recipients := prettyDraftAddresses(projection.To); recipients != "" { + fmt.Fprintf(w, "to: %s\n", sanitizeForTerminal(recipients)) + } + if recipients := prettyDraftAddresses(projection.Cc); recipients != "" { + fmt.Fprintf(w, "cc: %s\n", sanitizeForTerminal(recipients)) + } + if projection.BodyText != "" { + fmt.Fprintf(w, "body_text: %s\n", sanitizeForTerminal(projection.BodyText)) + } + if projection.BodyHTMLSummary != "" { + fmt.Fprintf(w, "body_html_summary: %s\n", sanitizeForTerminal(projection.BodyHTMLSummary)) + } + if projection.HasQuotedContent { + fmt.Fprintln(w, "has_quoted_content: true (use set_reply_body op in --patch-file to edit body while preserving the quote)") + } + if len(projection.AttachmentsSummary) > 0 { + fmt.Fprintf(w, "attachments (%d):\n", len(projection.AttachmentsSummary)) + for _, att := range projection.AttachmentsSummary { + fmt.Fprintf(w, " - part_id=%s filename=%s content_type=%s cid=%s\n", + att.PartID, att.FileName, att.ContentType, att.CID) + } + } + if len(projection.InlineSummary) > 0 { + fmt.Fprintf(w, "inline_parts (%d):\n", len(projection.InlineSummary)) + for _, inl := range projection.InlineSummary { + fmt.Fprintf(w, " - part_id=%s filename=%s content_type=%s cid=%s\n", + inl.PartID, inl.FileName, inl.ContentType, inl.CID) + } + } + if len(projection.Warnings) > 0 { + fmt.Fprintf(w, "warnings: %s\n", sanitizeForTerminal(strings.Join(projection.Warnings, "; "))) + } + }) + return nil +} + +func prettyDraftAddresses(addrs []draftpkg.Address) string { + if len(addrs) == 0 { + return "" + } + parts := make([]string, 0, len(addrs)) + for _, addr := range addrs { + parts = append(parts, addr.String()) + } + return strings.Join(parts, ", ") +} + +func buildDraftEditPatch(runtime *common.RuntimeContext) (draftpkg.Patch, error) { + patch := draftpkg.Patch{ + Options: draftpkg.PatchOptions{ + AllowProtectedHeaderEdits: runtime.Bool("allow-protected-header-edit"), + RewriteEntireDraft: runtime.Bool("rewrite-entire-draft"), + }, + } + + patchFile := strings.TrimSpace(runtime.Str("patch-file")) + if patchFile != "" { + filePatch, err := loadPatchFile(patchFile) + if err != nil { + return patch, err + } + patch.Ops = append(patch.Ops, filePatch.Ops...) + if filePatch.Options.AllowProtectedHeaderEdits { + patch.Options.AllowProtectedHeaderEdits = true + } + if filePatch.Options.RewriteEntireDraft { + patch.Options.RewriteEntireDraft = true + } + } + + setRecipients := func(field, raw string) { + if strings.TrimSpace(raw) == "" { + return + } + addrs := parseNetAddrs(raw) + opAddrs := make([]draftpkg.Address, 0, len(addrs)) + for _, addr := range addrs { + opAddrs = append(opAddrs, draftpkg.Address{ + Name: addr.Name, + Address: addr.Address, + }) + } + patch.Ops = append(patch.Ops, draftpkg.PatchOp{ + Op: "set_recipients", + Field: field, + Addresses: opAddrs, + }) + } + + if value := strings.TrimSpace(runtime.Str("set-subject")); value != "" { + patch.Ops = append(patch.Ops, draftpkg.PatchOp{Op: "set_subject", Value: value}) + } + if err := validateRecipientCount(runtime.Str("set-to"), runtime.Str("set-cc"), runtime.Str("set-bcc")); err != nil { + return patch, err + } + setRecipients("to", runtime.Str("set-to")) + setRecipients("cc", runtime.Str("set-cc")) + setRecipients("bcc", runtime.Str("set-bcc")) + + if len(patch.Ops) == 0 { + return patch, output.ErrValidation("at least one edit operation is required; use direct flags such as --set-subject/--set-to, or use --patch-file for body edits and other advanced operations (run --print-patch-template first)") + } + return patch, patch.Validate() +} + +func loadPatchFile(path string) (draftpkg.Patch, error) { + var patch draftpkg.Patch + safePath, err := validate.SafeInputPath(path) + if err != nil { + return patch, fmt.Errorf("--patch-file %q: %w", path, err) + } + data, err := os.ReadFile(safePath) + if err != nil { + return patch, err + } + if err := json.Unmarshal(data, &patch); err != nil { + return patch, fmt.Errorf("parse patch file: %w", err) + } + return patch, patch.Validate() +} + +func buildDraftEditPatchTemplate() map[string]interface{} { + return map[string]interface{}{ + "description": "Typed patch JSON for `mail +draft-edit --patch-file`. This is not RFC 6902 JSON Patch.", + "template": map[string]interface{}{ + "ops": []map[string]interface{}{ + {"op": "set_subject", "value": "Updated subject"}, + {"op": "set_recipients", "field": "to", "addresses": []map[string]interface{}{{"address": "alice@example.com", "name": "Alice"}}}, + {"op": "set_body", "value": "Updated body"}, + }, + "options": map[string]interface{}{ + "rewrite_entire_draft": false, + "allow_protected_header_edits": false, + }, + }, + "options_help": map[string]interface{}{ + "rewrite_entire_draft": "Default false. Set to true only when the edit must synthesize or restructure body parts, for example adding a missing primary body part.", + "allow_protected_header_edits": "Default false. Set to true only when the user explicitly wants to edit protected headers and understands the threading or delivery risk.", + }, + "supported_ops": []map[string]interface{}{ + {"op": "set_subject", "shape": map[string]interface{}{"value": "string"}}, + {"op": "set_recipients", "shape": map[string]interface{}{"field": "to|cc|bcc", "addresses": []map[string]interface{}{{"address": "string", "name": "string(optional)"}}}}, + {"op": "add_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string", "name": "string(optional)"}}, + {"op": "remove_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string"}}, + {"op": "set_body", "shape": map[string]interface{}{"value": "string"}}, + {"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically)"}}, + {"op": "set_header", "shape": map[string]interface{}{"name": "string", "value": "string"}}, + {"op": "remove_header", "shape": map[string]interface{}{"name": "string"}}, + {"op": "add_attachment", "shape": map[string]interface{}{"path": "string"}}, + {"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, + {"op": "add_inline", "shape": map[string]interface{}{"path": "string", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}}, + {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, + {"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, + }, + "supported_ops_by_group": []map[string]interface{}{ + { + "group": "subject_and_body", + "ops": []map[string]interface{}{ + {"op": "set_subject", "shape": map[string]interface{}{"value": "string"}}, + {"op": "set_body", "shape": map[string]interface{}{"value": "string"}}, + {"op": "set_reply_body", "shape": map[string]interface{}{"value": "string (user-authored content only, WITHOUT the quote block; the quote block is re-appended automatically)"}}, + }, + }, + { + "group": "recipients", + "ops": []map[string]interface{}{ + {"op": "set_recipients", "shape": map[string]interface{}{"field": "to|cc|bcc", "addresses": []map[string]interface{}{{"address": "string", "name": "string(optional)"}}}}, + {"op": "add_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string", "name": "string(optional)"}}, + {"op": "remove_recipient", "shape": map[string]interface{}{"field": "to|cc|bcc", "address": "string"}}, + }, + }, + { + "group": "headers", + "ops": []map[string]interface{}{ + {"op": "set_header", "shape": map[string]interface{}{"name": "string", "value": "string"}}, + {"op": "remove_header", "shape": map[string]interface{}{"name": "string"}}, + }, + }, + { + "group": "attachments_and_inline", + "ops": []map[string]interface{}{ + {"op": "add_attachment", "shape": map[string]interface{}{"path": "string"}}, + {"op": "remove_attachment", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, + {"op": "add_inline", "shape": map[string]interface{}{"path": "string", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}}, + {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, + {"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, + }, + }, + }, + "recommended_usage": []string{ + "Use direct flags (--set-subject, --set-to, --set-cc, --set-bcc) for simple metadata edits", + "Use --patch-file for ALL body edits and advanced changes (recipients, headers, attachments, inline images)", + "Before editing body, run --inspect to check has_quoted_content; if true, use set_reply_body instead of set_body", + }, + "body_edit_decision_guide": []map[string]interface{}{ + {"situation": "plain draft or non-reply/forward draft", "recommended_op": "set_body — replaces entire body"}, + {"situation": "draft has both text/plain and text/html", "recommended_op": "set_body — updates HTML body and regenerates plain-text summary; pass HTML input because the original main body is text/html"}, + {"situation": "draft created by +reply or +forward (has_quoted_content=true)", "recommended_op": "set_reply_body — replaces only the user-authored portion and automatically preserves the quoted original message; if user explicitly wants to remove the quote, use set_body instead"}, + }, + "notes": []string{ + "`ops` is executed in order", + "all body edits MUST go through --patch-file; there is no --set-body flag", + "`set_body` replaces the ENTIRE body including any reply/forward quote block; when the draft has both text/plain and text/html, it updates the HTML body and regenerates the plain-text summary, so the input should be HTML", + "`set_reply_body` replaces only the user-authored portion of the body and automatically re-appends the trailing reply/forward quote block (generated by +reply or +forward); the value you pass should contain ONLY the new user-authored content WITHOUT the quote block — the quote block will be re-inserted automatically; if the user wants to modify content INSIDE the quote block, use `set_body` instead for full replacement; if the draft has no quote block, it behaves identically to `set_body`", + "`add_inline` only adds the MIME binary part; it does NOT insert an tag into the HTML body; to display the image in the body, you must ALSO use set_body/set_reply_body to insert into the body content; forgetting this causes the inline part to become an orphaned attachment when sent", + "`body_kind` only supports text/plain and text/html", + "`selector` currently only supports primary", + "`remove_attachment` target supports part_id or cid; priority: part_id > cid", + "`remove_attachment`/`remove_inline` require part_id or cid; to discover these values, run `+draft-edit --draft-id --inspect` first — the response `projection.attachments_summary` and `projection.inline_summary` list every part with its part_id, cid, and filename", + "`add_inline`/`replace_inline`/`remove_inline` are for CID-based inline images", + "`replace_inline` keeps the original filename and content_type when those fields are omitted", + "protected headers require `allow_protected_header_edits=true`", + }, + "command_example": "lark-cli mail +draft-edit --print-patch-template", + "patch_file_example": "lark-cli mail +draft-edit --draft-id d_xxx --patch-file ./patch.json", + } +} diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go new file mode 100644 index 00000000..7af87421 --- /dev/null +++ b/shortcuts/mail/mail_forward.go @@ -0,0 +1,213 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/larksuite/cli/shortcuts/common" + draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" + "github.com/larksuite/cli/shortcuts/mail/emlbuilder" +) + +var MailForward = common.Shortcut{ + Service: "mail", + Command: "+forward", + Description: "Forward a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Original message block included automatically.", + Risk: "write", + Scopes: []string{"mail:user_mailbox.message:send", "mail:user_mailbox.message:modify", "mail:user_mailbox.message:readonly", "mail:user_mailbox:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, + AuthTypes: []string{"user"}, + Flags: []common.Flag{ + {Name: "message-id", Desc: "Required. Message ID to forward", Required: true}, + {Name: "to", Desc: "Recipient email address(es), comma-separated"}, + {Name: "body", Desc: "Body prepended before the forwarded message. Prefer HTML for rich formatting; plain text is also supported. Body type is auto-detected from the forward body and the original message. Use --plain-text to force plain-text mode."}, + {Name: "from", Desc: "Sender address; also selects the mailbox to send from (defaults to the authenticated user's primary mailbox)"}, + {Name: "cc", Desc: "CC email address(es), comma-separated"}, + {Name: "bcc", Desc: "BCC email address(es), comma-separated"}, + {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring all HTML auto-detection. Cannot be used with --inline."}, + {Name: "attach", Desc: "Attachment file path(s), comma-separated (appended after original attachments)"}, + {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. Cannot be used with --plain-text. CID images are embedded via in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."}, + {Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + messageId := runtime.Str("message-id") + to := runtime.Str("to") + confirmSend := runtime.Bool("confirm-send") + mailboxID := resolveComposeMailboxID(runtime) + desc := "Forward: fetch original message → fetch mailbox profile (default From) → save as draft" + if confirmSend { + desc = "Forward (--confirm-send): fetch original message → fetch mailbox profile (default From) → create draft → send draft" + } + api := common.NewDryRunAPI(). + Desc(desc). + GET(mailboxPath(mailboxID, "messages", messageId)). + GET(mailboxPath(mailboxID, "profile")). + POST(mailboxPath(mailboxID, "drafts")). + Body(map[string]interface{}{"raw": "", "_to": to}) + if confirmSend { + api = api.POST(mailboxPath(mailboxID, "drafts", "", "send")) + } + return api + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Bool("confirm-send") { + if err := validateComposeHasAtLeastOneRecipient(runtime.Str("to"), runtime.Str("cc"), runtime.Str("bcc")); err != nil { + return err + } + } + return validateComposeInlineAndAttachments(runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + messageId := runtime.Str("message-id") + to := runtime.Str("to") + body := runtime.Str("body") + fromFlag := runtime.Str("from") + ccFlag := runtime.Str("cc") + bccFlag := runtime.Str("bcc") + plainText := runtime.Bool("plain-text") + attachFlag := runtime.Str("attach") + inlineFlag := runtime.Str("inline") + confirmSend := runtime.Bool("confirm-send") + + mailboxID := resolveComposeMailboxID(runtime) + sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId) + if err != nil { + return fmt.Errorf("failed to fetch original message: %w", err) + } + if err := validateForwardAttachmentURLs(sourceMsg); err != nil { + return fmt.Errorf("forward blocked: %w", err) + } + orig := sourceMsg.Original + + senderEmail := fromFlag + if senderEmail == "" { + senderEmail = fetchCurrentUserEmail(runtime) + if senderEmail == "" { + senderEmail = orig.headTo + } + } + + if err := validateRecipientCount(to, ccFlag, bccFlag); err != nil { + return err + } + + bld := emlbuilder.New(). + Subject(buildForwardSubject(orig.subject)). + ToAddrs(parseNetAddrs(to)) + if senderEmail != "" { + bld = bld.From("", senderEmail) + } + if ccFlag != "" { + bld = bld.CCAddrs(parseNetAddrs(ccFlag)) + } + if bccFlag != "" { + bld = bld.BCCAddrs(parseNetAddrs(bccFlag)) + } + if inReplyTo := normalizeMessageID(orig.smtpMessageId); inReplyTo != "" { + bld = bld.InReplyTo(inReplyTo) + } + if messageId != "" { + bld = bld.LMSReplyToMessageID(messageId) + } + useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw)) + if strings.TrimSpace(inlineFlag) != "" && !useHTML { + return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML") + } + if useHTML { + if err := validateInlineImageURLs(sourceMsg); err != nil { + return fmt.Errorf("forward blocked: %w", err) + } + processedBody := buildBodyDiv(body, bodyIsHTML(body)) + bld = bld.HTMLBody([]byte(processedBody + buildForwardQuoteHTML(&orig))) + bld, err = addInlineImagesToBuilder(runtime, bld, sourceMsg.InlineImages) + if err != nil { + return err + } + } else { + bld = bld.TextBody([]byte(buildForwardedMessage(&orig, body))) + } + // Download original attachments and accumulate size for limit check + type downloadedAtt struct { + content []byte + contentType string + filename string + } + var origAtts []downloadedAtt + var origAttBytes int64 + type largeAttID struct { + ID string `json:"id"` + } + var largeAttIDs []largeAttID + for _, att := range sourceMsg.ForwardAttachments { + if att.AttachmentType == attachmentTypeLarge { + largeAttIDs = append(largeAttIDs, largeAttID{ID: att.ID}) + continue + } + content, err := downloadAttachmentContent(runtime, att.DownloadURL) + if err != nil { + return fmt.Errorf("failed to download original attachment %s: %w", att.Filename, err) + } + contentType := att.ContentType + if contentType == "" { + contentType = "application/octet-stream" + } + origAtts = append(origAtts, downloadedAtt{content, contentType, att.Filename}) + origAttBytes += int64(len(content)) + } + if len(largeAttIDs) > 0 { + idsJSON, err := json.Marshal(largeAttIDs) + if err != nil { + return fmt.Errorf("failed to encode large attachment IDs: %w", err) + } + bld = bld.Header("X-Lms-Large-Attachment-Ids", base64.StdEncoding.EncodeToString(idsJSON)) + } + inlineSpecs, err := parseInlineSpecs(inlineFlag) + if err != nil { + return err + } + if err := checkAttachmentSizeLimit(append(splitByComma(attachFlag), inlineSpecFilePaths(inlineSpecs)...), origAttBytes, len(origAtts)); err != nil { + return err + } + for _, att := range origAtts { + bld = bld.AddAttachment(att.content, att.contentType, att.filename) + } + for _, path := range splitByComma(attachFlag) { + bld = bld.AddFileAttachment(path) + } + for _, spec := range inlineSpecs { + bld = bld.AddFileInline(spec.FilePath, spec.CID) + } + rawEML, err := bld.BuildBase64URL() + if err != nil { + return fmt.Errorf("failed to build EML: %w", err) + } + + draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) + if err != nil { + return fmt.Errorf("failed to create draft: %w", err) + } + if !confirmSend { + runtime.Out(map[string]interface{}{ + "draft_id": draftID, + "tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftID), + }, nil) + hintSendDraft(runtime, mailboxID, draftID) + return nil + } + resData, err := draftpkg.Send(runtime, mailboxID, draftID) + if err != nil { + return fmt.Errorf("failed to send forward (draft %s created but not sent): %w", draftID, err) + } + runtime.Out(map[string]interface{}{ + "message_id": resData["message_id"], + "thread_id": resData["thread_id"], + }, nil) + hintMarkAsRead(runtime, mailboxID, messageId) + return nil + }, +} diff --git a/shortcuts/mail/mail_message.go b/shortcuts/mail/mail_message.go new file mode 100644 index 00000000..f5045be8 --- /dev/null +++ b/shortcuts/mail/mail_message.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/shortcuts/common" +) + +var MailMessage = common.Shortcut{ + Service: "mail", + Command: "+message", + Description: "Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images.", + Risk: "read", + Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, + {Name: "message-id", Desc: "Required. Email message ID", Required: true}, + {Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"}, + {Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + messageID := runtime.Str("message-id") + return common.NewDryRunAPI(). + Desc("Fetch full email content and attachments metadata, including inline images"). + GET(mailboxPath(mailboxID, "messages", messageID)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Bool("print-output-schema") { + printMessageOutputSchema(runtime) + return nil + } + mailboxID := resolveMailboxID(runtime) + hintIdentityFirst(runtime, mailboxID) + messageID := runtime.Str("message-id") + html := runtime.Bool("html") + + msg, err := fetchFullMessage(runtime, mailboxID, messageID, html) + if err != nil { + return fmt.Errorf("failed to fetch email: %w", err) + } + + out := buildMessageOutput(msg, html) + runtime.Out(out, nil) + return nil + }, +} diff --git a/shortcuts/mail/mail_messages.go b/shortcuts/mail/mail_messages.go new file mode 100644 index 00000000..10cd3329 --- /dev/null +++ b/shortcuts/mail/mail_messages.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +type mailMessagesOutput struct { + Messages []map[string]interface{} `json:"messages"` + Total int `json:"total"` + UnavailableMessageIDs []string `json:"unavailable_message_ids,omitempty"` +} + +var MailMessages = common.Shortcut{ + Service: "mail", + Command: "+messages", + Description: "Use when reading full content for multiple emails by message ID. Prefer this shortcut over calling raw mail user_mailbox.messages batch_get directly, because it base64url-decodes body fields and returns normalized per-message output that is easier to consume.", + Risk: "read", + Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "mailbox", Default: "me", Desc: "email address (default: me)"}, + {Name: "message-ids", Desc: `Required. Comma-separated email message IDs. Example: "id1,id2,id3"`, Required: true}, + {Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"}, + {Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := resolveMailboxID(runtime) + messageIDs := splitByComma(runtime.Str("message-ids")) + body := map[string]interface{}{ + "format": messageGetFormat(runtime.Bool("html")), + "message_ids": []string{"", ""}, + } + if len(messageIDs) > 0 { + body["message_ids"] = messageIDs + } + return common.NewDryRunAPI(). + Desc("Fetch multiple emails via messages.batch_get (auto-chunked in batches of 20 IDs during execution)"). + POST(mailboxPath(mailboxID, "messages", "batch_get")). + Body(body) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Bool("print-output-schema") { + printMessageOutputSchema(runtime) + return nil + } + mailboxID := resolveMailboxID(runtime) + hintIdentityFirst(runtime, mailboxID) + messageIDs := splitByComma(runtime.Str("message-ids")) + if len(messageIDs) == 0 { + return output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas") + } + html := runtime.Bool("html") + + rawMessages, missingMessageIDs, err := fetchFullMessages(runtime, mailboxID, messageIDs, html) + if err != nil { + return err + } + + messages := make([]map[string]interface{}, 0, len(rawMessages)) + for _, msg := range rawMessages { + messages = append(messages, buildMessageOutput(msg, html)) + } + + runtime.Out(mailMessagesOutput{ + Messages: messages, + Total: len(messages), + UnavailableMessageIDs: missingMessageIDs, + }, nil) + return nil + }, +} diff --git a/shortcuts/mail/mail_quote.go b/shortcuts/mail/mail_quote.go new file mode 100644 index 00000000..5abbcb75 --- /dev/null +++ b/shortcuts/mail/mail_quote.go @@ -0,0 +1,585 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "fmt" + "math/rand" + "net/url" + "regexp" + "strings" + "time" + + draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" +) + +// ---- HTML quote block builders (Lark adit-html-block structure) --------------- +// +// These helpers mirror the structure used by Lark's mail composer: +// - Reply/Reply-all: adit-html-block--collapsed (collapsible quoted block) +// - Forward: adit-html-block--header (always-expanded header block) + +// ---- CSS class / style constants ------------------------------------------- + +const ( + // quoteIDChars is the character set for generated quote element IDs. + quoteIDChars = "abcdefghijklmnopqrstuvwxyz0123456789" + + // quoteBlockBorderStyle is the shared border style for all adit-html-block wrappers. + quoteBlockBorderStyle = "border-left: none; padding-left: 0px;" + + // metaBlockStyle is the shared style for quote meta info blocks (From/Date/Subject/To/Cc). + metaBlockStyle = "padding: 12px; background: rgb(245, 246, 247); color: rgb(31, 35, 41); border-radius: 4px; margin-bottom: 12px;" + + // replyMetaMargin is the margin-top applied to reply/reply-all meta blocks. + replyMetaMargin = "margin-top: 24px;" + + // forwardMetaMargin is the margin-top applied to forward meta blocks (closer to separator). + forwardMetaMargin = "margin-top: 2px;" + + // separatorStyle is the style for the forward separator line. + separatorStyle = "color: rgb(100, 106, 115); margin-top: 24px; margin-bottom: 8px;" + + // bodyDivStyle is applied to the user-authored body
in HTML emails. + bodyDivStyle = "word-break:break-word;line-height:1.6;font-size:14px;color:rgb(0,0,0);" + + // addressAnchorStyle is the inline style for mailto hyperlinks. + addressAnchorStyle = "overflow-wrap: break-word; color: inherit; text-decoration: none; white-space: pre-wrap; hyphens: none; word-break: break-word; cursor: pointer;" +) + +// ---- HTML format-string templates ------------------------------------------ +// All structural HTML is kept here so functions contain only logic, not markup. + +const ( + // addressAnchorFmt renders a mailto element: (escapedAddr, escapedAddr, escapedAddr). + addressAnchorFmt = `%s` + + // metaRowFmt renders one labeled metadata row (label, content). + metaRowFmt = `
%s: %s
` + + // replyMetaWrapperFmt wraps meta rows for reply/reply-all (margin, style, inner). + replyMetaWrapperFmt = `
%s
` + + // forwardMetaWrapperFmt wraps meta rows for forward (id, margin, style, inner). + forwardMetaWrapperFmt = `
%s
` + + // separatorDivFmt renders the forward separator line (style, text). + separatorDivFmt = `
%s
` + + // plainTextBodyFmt wraps a plain-text quoted body for use inside HTML emails (escapedText). + plainTextBodyFmt = `
%s
` +) + +// ---- Quote wrapper formats (var, not const, because they reference draftpkg.QuoteWrapperClass) --- + +var ( + // replyQuoteHTMLFmt is the outer collapsed-block structure for reply (style, prefix, body). + replyQuoteHTMLFmt = `
` + + `
` + + `
%s%s
` + + `
` + + // forwardQuoteHTMLFmt is the outer header-block structure for forward (outerID, style, innerID, sep, meta, body). + forwardQuoteHTMLFmt = `
` + + `
` + + `
%s%s%s
` + + `
` +) + +// genID returns an element id of the form "XXXXXX" +// where XXXXXX is 6 random alphanumeric characters. +func genID(prefix string) string { + b := make([]byte, 6) + for i := range b { + b[i] = quoteIDChars[rand.Intn(len(quoteIDChars))] + } + return prefix + string(b) +} + +// detectSubjectLang returns "zh" if the subject contains CJK characters, "en" otherwise. +func detectSubjectLang(subject string) string { + for _, r := range subject { + if (r >= '\u4e00' && r <= '\u9fff') || // CJK Unified Ideographs + (r >= '\u3400' && r <= '\u4dbf') || // CJK Extension A + (r >= '\uf900' && r <= '\ufaff') || // CJK Compatibility Ideographs + (r >= '\u3040' && r <= '\u30ff') { // Hiragana + Katakana + return "zh" + } + } + return "en" +} + +type quoteMetaLabelSet struct { + From string + Date string + Subject string + To string + Cc string + Separator string // plaintext forward separator line + Colon string // ":" for Chinese, ": " for English (used in plaintext) + ReplyPrefix string // subject prefix for reply, e.g. "Re: " or "回复:" + ForwardPrefix string // subject prefix for forward, e.g. "Fwd: " or "转发:" +} + +// quoteMetaLabels returns the label set appropriate for the given subject language. +func quoteMetaLabels(subject string) quoteMetaLabelSet { + if detectSubjectLang(subject) == "zh" { + return quoteMetaLabelSet{ + From: "发件人", + Date: "时间", + Subject: "主题", + To: "收件人", + Cc: "抄送", + Separator: "--------- 转发消息 ---------", + Colon: ":", + ReplyPrefix: "回复:", + ForwardPrefix: "转发:", + } + } + return quoteMetaLabelSet{ + From: "From", + Date: "Date", + Subject: "Subject", + To: "To", + Cc: "Cc", + Separator: "---------- Forwarded message ---------", + Colon: ": ", + ReplyPrefix: "Re: ", + ForwardPrefix: "Fwd: ", + } +} + +// buildAddressAnchor renders an email address as a mailto hyperlink (
only). +// The href uses URL encoding (RFC 6068) to prevent mailto: parameter injection; +// the display text and data attribute use HTML entity encoding. +func buildAddressAnchor(addr string) string { + urlEncoded := url.PathEscape(addr) + displayText := htmlEscape(addr) + return fmt.Sprintf(addressAnchorFmt, displayText, urlEncoded, displayText) +} + +// buildAddressHTML renders a single address. +// +// With name: "Name"<addr> +// Without name: <addr> +func buildAddressHTML(name, addr string) string { + anchor := buildAddressAnchor(addr) + if name != "" { + return fmt.Sprintf(`"%s"<%s>`, htmlEscape(name), anchor) + } + return `<` + anchor + `>` +} + +// buildAddressPairListHTML renders a list of name+email pairs. +// Each address is wrapped in its own . +func buildAddressPairListHTML(pairs []mailAddressPair) string { + if len(pairs) == 0 { + return "" + } + items := make([]string, 0, len(pairs)) + for _, p := range pairs { + items = append(items, ``+buildAddressHTML(p.Name, p.Email)+``) + } + return strings.Join(items, ", ") +} + +// buildAddressListHTML renders a list of email-only addresses. +// Kept as a fallback for contexts where display names are unavailable. +func buildAddressListHTML(addrs []string) string { + if len(addrs) == 0 { + return "" + } + items := make([]string, 0, len(addrs)) + for _, addr := range addrs { + items = append(items, ``+buildAddressHTML("", addr)+``) + } + return strings.Join(items, ", ") +} + +// buildMetaRow renders a single labeled metadata row. +// The entire content (label + value) is wrapped in . +func buildMetaRow(label, content string) string { + return fmt.Sprintf(metaRowFmt, htmlEscape(label), content) +} + +// buildReplyMetaWrapper wraps meta rows for reply/reply-all quote blocks. +func buildReplyMetaWrapper(inner string) string { + return fmt.Sprintf(replyMetaWrapperFmt, replyMetaMargin, metaBlockStyle, inner) +} + +// buildForwardMetaWrapper wraps meta rows for forward quote blocks. +// Uses adit-html-block__header class and margin-top: 2px (not 24px). +func buildForwardMetaWrapper(inner string) string { + return fmt.Sprintf(forwardMetaWrapperFmt, genID("lark-mail-meta-cli"), forwardMetaMargin, metaBlockStyle, inner) +} + +// buildMetaRows assembles the inner HTML rows (From/Date/Subject/To/Cc) shared by +// both reply and forward quote blocks. +func buildMetaRows(orig *originalMessage) string { + labels := quoteMetaLabels(orig.subject) + var rows strings.Builder + rows.WriteString(buildMetaRow(labels.From, buildAddressHTML(orig.headFromName, orig.headFrom))) + if orig.headDate != "" { + rows.WriteString(buildMetaRow(labels.Date, htmlEscape(orig.headDate))) + } + if orig.subject != "" { + rows.WriteString(buildMetaRow(labels.Subject, htmlEscape(orig.subject))) + } + if len(orig.toAddressesFull) > 0 { + rows.WriteString(buildMetaRow(labels.To, buildAddressPairListHTML(orig.toAddressesFull))) + } else if len(orig.toAddresses) > 0 { + rows.WriteString(buildMetaRow(labels.To, buildAddressListHTML(orig.toAddresses))) + } + if len(orig.ccAddressesFull) > 0 { + rows.WriteString(buildMetaRow(labels.Cc, buildAddressPairListHTML(orig.ccAddressesFull))) + } else if len(orig.ccAddresses) > 0 { + rows.WriteString(buildMetaRow(labels.Cc, buildAddressListHTML(orig.ccAddresses))) + } + return rows.String() +} + +// buildReplyPrefixHTML constructs the metadata prefix block for reply emails. +// Rendered as adit-html-block__attr with From/Date/Subject/To/Cc fields. +func buildReplyPrefixHTML(orig *originalMessage) string { + return buildReplyMetaWrapper(buildMetaRows(orig)) +} + +// buildBodyDiv wraps the user-authored body content in a styled
. +// If isHTML is true, content is embedded as-is; otherwise it is HTML-escaped. +func buildBodyDiv(content string, isHTML bool) string { + if content == "" { + return "" + } + var inner string + if isHTML { + inner = content + } else { + inner = strings.ReplaceAll(htmlEscape(content), "\n", "
") + } + return fmt.Sprintf(`
%s
`, bodyDivStyle, inner) +} + +// buildReplyQuoteHTML builds the collapsed quote block for reply/reply-all emails. +// Returns empty string when there is no content to quote. +func buildReplyQuoteHTML(orig *originalMessage) string { + if orig.bodyRaw == "" && orig.headFrom == "" { + return "" + } + prefixHTML := buildReplyPrefixHTML(orig) + bodyHTML := orig.bodyRaw + if bodyHTML != "" && !bodyIsHTML(bodyHTML) { + bodyHTML = fmt.Sprintf(plainTextBodyFmt, htmlEscape(bodyHTML)) + } + var bodyPart string + if bodyHTML != "" { + bodyPart = `
` + bodyHTML + `
` + } + return fmt.Sprintf(replyQuoteHTMLFmt, quoteBlockBorderStyle, prefixHTML, bodyPart) +} + +// buildForwardSeparatorHTML builds the separator line div placed before the meta block. +func buildForwardSeparatorHTML(orig *originalMessage) string { + labels := quoteMetaLabels(orig.subject) + return fmt.Sprintf(separatorDivFmt, separatorStyle, htmlEscape(labels.Separator)) +} + +// buildForwardMetaHTML constructs the meta block (From/Date/Subject/To/Cc) for forwarded emails. +func buildForwardMetaHTML(orig *originalMessage) string { + return buildForwardMetaWrapper(buildMetaRows(orig)) +} + +// buildForwardQuoteHTML builds the header quote block for forwarded emails. +// The separator div is placed outside the meta wrapper, matching the Lark client structure. +func buildForwardQuoteHTML(orig *originalMessage) string { + separatorHTML := buildForwardSeparatorHTML(orig) + metaHTML := buildForwardMetaHTML(orig) + bodyHTML := orig.bodyRaw + if bodyHTML != "" && !bodyIsHTML(bodyHTML) { + bodyHTML = fmt.Sprintf(plainTextBodyFmt, htmlEscape(bodyHTML)) + } + var bodyPart string + if bodyHTML != "" { + bodyPart = `
` + bodyHTML + `
` + } + return fmt.Sprintf(forwardQuoteHTMLFmt, genID("lark-mail-quote-cli"), quoteBlockBorderStyle, genID("lark-mail-quote-cli"), separatorHTML, metaHTML, bodyPart) +} + +// zhWeekdays maps time.Weekday to Chinese weekday names in Lark's format. +var zhWeekdays = [7]string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"} + +// formatMailDate formats a Unix millisecond timestamp for use in quote blocks. +// lang is the detected language ("zh" or "en", from detectSubjectLang). +// The time is rendered in the system local timezone: +// - "zh": "2006年1月2日 (周X) 15:04" (matches Feishu native client) +// - "en": "Mon, 02 Jan 2006 15:04 MST" +func formatMailDate(ms int64, lang string) string { + t := time.UnixMilli(ms).Local() + if lang == "zh" { + return fmt.Sprintf("%s (%s) %s", + t.Format("2006年1月2日"), + zhWeekdays[t.Weekday()], + t.Format("15:04")) + } + return t.Format("Mon, 02 Jan 2006 15:04 MST") +} + +// htmlTagRe matches known HTML tags followed by a tag-terminating character +// (whitespace, >, or />). This avoids false positives like "" or +// "price < 100". The tag list follows the Chromium/WHATWG MIME sniffing approach +// with additional common tags for email content. +var htmlTagRe = regexp.MustCompile( + `(?i)<(?:` + + `!doctype\s+html|!--|` + + `html|head|body|div|p|br|span|a|b|i|em|strong|` + + `h[1-6]|ul|ol|li|table|tr|td|th|img|font|style|script|` + + `iframe|title|form|input|select|textarea|button|label|` + + `blockquote|pre|code|hr|section|article|header|footer|nav|main` + + `)[\s/>]`) + +// bodyIsHTML reports whether s appears to contain HTML markup. +func bodyIsHTML(s string) bool { + if !strings.Contains(s, "<") { + return false + } + return htmlTagRe.MatchString(s) +} + +// htmlEscape escapes the five standard XML/HTML special characters. +func htmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, `"`, """) + s = strings.ReplaceAll(s, "'", "'") + return s +} + +// stripHTMLForQuote converts an HTML body to plain text suitable for quoted replies. +func stripHTMLForQuote(s string) string { + var b strings.Builder + b.Grow(len(s)) + i := 0 + for i < len(s) { + if s[i] != '<' { + b.WriteByte(s[i]) + i++ + continue + } + end := strings.IndexByte(s[i:], '>') + if end < 0 { + b.WriteString(s[i:]) + break + } + tag := strings.ToLower(strings.TrimSpace(s[i+1 : i+end])) + i += end + 1 + + fields := strings.Fields(tag) + if len(fields) > 0 && (fields[0] == "script" || fields[0] == "style") { + closeTag := "" + if idx := strings.Index(strings.ToLower(s[i:]), closeTag); idx >= 0 { + i += idx + len(closeTag) + } + continue + } + + switch { + case tag == "br" || tag == "br/" || tag == "br /": + b.WriteByte('\n') + case strings.HasPrefix(tag, "/p") || strings.HasPrefix(tag, "/div") || + strings.HasPrefix(tag, "/tr") || tag == "/h1" || tag == "/h2" || + tag == "/h3" || tag == "/h4" || tag == "/h5" || tag == "/h6" || + tag == "/li": + b.WriteByte('\n') + } + } + + result := b.String() + result = strings.ReplaceAll(result, "&", "&") + result = strings.ReplaceAll(result, "<", "<") + result = strings.ReplaceAll(result, ">", ">") + result = strings.ReplaceAll(result, """, `"`) + result = strings.ReplaceAll(result, "'", "'") + result = strings.ReplaceAll(result, " ", " ") + + for strings.Contains(result, "\n\n\n") { + result = strings.ReplaceAll(result, "\n\n\n", "\n\n") + } + return strings.TrimSpace(result) +} + +// quoteForReply formats the original message body as a quoted block. +// HTML replies use the Lark adit-html-block--collapsed structure; +// plain-text replies use the classic "> " prefix format with meta header. +func quoteForReply(orig *originalMessage, html bool) string { + if html { + return buildReplyQuoteHTML(orig) + } + + // Plain-text path: meta header + "> " prefixed body lines. + if orig.bodyRaw == "" && orig.headFrom == "" { + return "" + } + var sb strings.Builder + sb.WriteString("\n\n") + // Build meta header lines (From/Subject/Date/To/Cc), each prefixed with "> " + sb.WriteString(buildReplyMetaPlainText(orig)) + // Blank line between meta and body + sb.WriteString(">\n") + text := stripHTMLForQuote(orig.bodyRaw) + for _, line := range strings.Split(text, "\n") { + sb.WriteString("> ") + sb.WriteString(line) + sb.WriteString("\n") + } + return sb.String() +} + +// buildReplyMetaPlainText builds the meta header block for plain-text replies. +// Each line is prefixed with "> " and follows the same label/format logic as HTML replies. +func buildReplyMetaPlainText(orig *originalMessage) string { + return buildPlainTextMetaRows(orig, "> ") +} + +// buildPlainTextMetaRows builds the meta rows for plain-text output. +// linePrefix is prepended to each line (e.g., "> " for replies, "" for forwards). +// Field order matches buildMetaRows: From -> Date -> Subject -> To -> Cc. +func buildPlainTextMetaRows(orig *originalMessage, linePrefix string) string { + labels := quoteMetaLabels(orig.subject) + var sb strings.Builder + + // From + if orig.headFrom != "" { + from := buildPlainTextAddress(orig.headFromName, orig.headFrom) + sb.WriteString(linePrefix) + sb.WriteString(labels.From) + sb.WriteString(labels.Colon) + sb.WriteString(from) + sb.WriteString("\n") + } + + // Date + if orig.headDate != "" { + sb.WriteString(linePrefix) + sb.WriteString(labels.Date) + sb.WriteString(labels.Colon) + sb.WriteString(orig.headDate) + sb.WriteString("\n") + } + + // Subject + if orig.subject != "" { + sb.WriteString(linePrefix) + sb.WriteString(labels.Subject) + sb.WriteString(labels.Colon) + sb.WriteString(orig.subject) + sb.WriteString("\n") + } + + // To + if len(orig.toAddressesFull) > 0 { + sb.WriteString(linePrefix) + sb.WriteString(labels.To) + sb.WriteString(labels.Colon) + sb.WriteString(buildPlainTextAddressList(orig.toAddressesFull)) + sb.WriteString("\n") + } else if len(orig.toAddresses) > 0 { + sb.WriteString(linePrefix) + sb.WriteString(labels.To) + sb.WriteString(labels.Colon) + sb.WriteString(strings.Join(orig.toAddresses, ", ")) + sb.WriteString("\n") + } + + // Cc + if len(orig.ccAddressesFull) > 0 { + sb.WriteString(linePrefix) + sb.WriteString(labels.Cc) + sb.WriteString(labels.Colon) + sb.WriteString(buildPlainTextAddressList(orig.ccAddressesFull)) + sb.WriteString("\n") + } else if len(orig.ccAddresses) > 0 { + sb.WriteString(linePrefix) + sb.WriteString(labels.Cc) + sb.WriteString(labels.Colon) + sb.WriteString(strings.Join(orig.ccAddresses, ", ")) + sb.WriteString("\n") + } + + return sb.String() +} + +// buildPlainTextAddress formats a single address for plain-text output. +// With name: "Name" +// Without name: +func buildPlainTextAddress(name, email string) string { + if name != "" { + return fmt.Sprintf(`"%s" <%s>`, name, email) + } + return fmt.Sprintf("<%s>", email) +} + +// buildPlainTextAddressList formats a list of addresses for plain-text output. +func buildPlainTextAddressList(pairs []mailAddressPair) string { + if len(pairs) == 0 { + return "" + } + items := make([]string, 0, len(pairs)) + for _, p := range pairs { + items = append(items, buildPlainTextAddress(p.Name, p.Email)) + } + return strings.Join(items, ", ") +} + +// removeDuplicateSubjectPrefix strips leading reply/forward prefixes (case-insensitive) +// to prevent accumulation on chained replies/forwards (e.g. "Re: Re: Re: topic"). +// Handles both ASCII prefixes (Re:, Fwd:, Fw:) and Chinese prefixes (回复:, 转发:). +func removeDuplicateSubjectPrefix(subject string) string { + for { + trimmed := strings.TrimSpace(subject) + lower := strings.ToLower(trimmed) + switch { + case strings.HasPrefix(lower, "re:"): + subject = strings.TrimSpace(trimmed[3:]) + case strings.HasPrefix(lower, "fwd:"): + subject = strings.TrimSpace(trimmed[4:]) + case strings.HasPrefix(lower, "fw:"): + subject = strings.TrimSpace(trimmed[3:]) + case strings.HasPrefix(trimmed, "回复:"): + subject = strings.TrimSpace(trimmed[len("回复:"):]) + case strings.HasPrefix(trimmed, "转发:"): + subject = strings.TrimSpace(trimmed[len("转发:"):]) + default: + return trimmed + } + } +} + +// buildReplySubject prepends the language-appropriate reply prefix once, +// stripping any existing prefixes to prevent accumulation on chained replies. +func buildReplySubject(original string) string { + return quoteMetaLabels(original).ReplyPrefix + removeDuplicateSubjectPrefix(original) +} + +// buildForwardSubject prepends the language-appropriate forward prefix once, +// stripping any existing prefixes to prevent accumulation on chained forwards. +func buildForwardSubject(original string) string { + return quoteMetaLabels(original).ForwardPrefix + removeDuplicateSubjectPrefix(original) +} + +// buildForwardedMessage formats the original message as a plain-text forwarding block. +func buildForwardedMessage(orig *originalMessage, body string) string { + labels := quoteMetaLabels(orig.subject) + var sb strings.Builder + if body != "" { + sb.WriteString(body) + sb.WriteString("\n\n") + } + sb.WriteString(labels.Separator + "\n") + sb.WriteString(buildPlainTextMetaRows(orig, "")) + sb.WriteString("\n") + if orig.bodyRaw != "" { + sb.WriteString(stripHTMLForQuote(orig.bodyRaw)) + } + return sb.String() +} diff --git a/shortcuts/mail/mail_quote_test.go b/shortcuts/mail/mail_quote_test.go new file mode 100644 index 00000000..5cef270e --- /dev/null +++ b/shortcuts/mail/mail_quote_test.go @@ -0,0 +1,389 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "strings" + "testing" +) + +func sampleOriginalMessage() originalMessage { + return originalMessage{ + subject: "Mail Shortcuts & Workflows 脑暴方案", + headFrom: "alice@example.com", + headFromName: "Alice", + headDate: "Sat, 21 Mar 2026 08:30:00 GMT", + toAddresses: []string{"bob@example.com", "carol@example.com"}, + ccAddresses: []string{"dave@example.com"}, + toAddressesFull: []mailAddressPair{ + {Email: "bob@example.com", Name: "Bob"}, + {Email: "carol@example.com", Name: "Carol"}, + }, + ccAddressesFull: []mailAddressPair{ + {Email: "dave@example.com", Name: "Dave"}, + }, + bodyRaw: "
hello world
", + } +} + +func sampleEnglishOriginalMessage() originalMessage { + orig := sampleOriginalMessage() + orig.subject = "Project Update" + return orig +} + +func TestQuoteMetaLabelsDefaultEnglish(t *testing.T) { + labels := quoteMetaLabels("Project Update") + if labels.From != "From" || labels.Date != "Date" || labels.Subject != "Subject" || labels.To != "To" || labels.Cc != "Cc" { + t.Fatalf("unexpected default labels: %+v", labels) + } +} + +func TestQuoteMetaLabelsChinese(t *testing.T) { + labels := quoteMetaLabels("脑暴方案") + if labels.From != "发件人" || labels.Date != "时间" || labels.Subject != "主题" || labels.To != "收件人" || labels.Cc != "抄送" { + t.Fatalf("unexpected Chinese labels: %+v", labels) + } + if labels.Separator != "--------- 转发消息 ---------" { + t.Fatalf("unexpected Chinese separator: %q", labels.Separator) + } + if labels.Colon != ":" { + t.Fatalf("unexpected Chinese colon: %q", labels.Colon) + } +} + +func TestBuildReplyQuoteHTMLStructure(t *testing.T) { + orig := sampleOriginalMessage() // subject contains Chinese + html := buildReplyQuoteHTML(&orig) + + mustContain(t, html, `class="history-quote-wrapper"`) + mustContain(t, html, `data-html-block="quote"`) + mustContain(t, html, `data-mail-html-ignore=""`) + mustContain(t, html, `class="adit-html-block adit-html-block--collapsed"`) + mustContain(t, html, `style="border-left: none; padding-left: 0px;"`) + mustContain(t, html, `class="adit-html-block__attr history-quote-meta-wrapper history-quote-gap-tag"`) + mustContain(t, html, `padding: 12px; background: rgb(245, 246, 247); color: rgb(31, 35, 41); border-radius: 4px; margin-bottom: 12px;`) + mustContain(t, html, `class="lme-line-signal"`) + mustContain(t, html, `class="quote-head-meta-mailto"`) + mustContain(t, html, `data-mailto="mailto:alice@example.com"`) + + // Chinese labels because subject contains CJK + mustContain(t, html, `发件人: `) + mustContain(t, html, `时间: `) + mustContain(t, html, `主题: `) + mustContain(t, html, `收件人: `) + mustContain(t, html, `抄送: `) + + // New style: text-decoration: none (not underline) + mustContain(t, html, `text-decoration: none`) + mustContain(t, html, `white-space: pre-wrap`) + mustContain(t, html, `cursor: pointer`) + + // Address with display name: "Bob"bob@example.com + mustContain(t, html, `"Bob"< + mustContain(t, html, `"Bob"<`) + mustContain(t, html, `"Carol"<`) + + // Body is plain
(not wrapped in lme-line-signal) + mustContain(t, html, `
hello world
`) + mustNotContain(t, html, `
hello`) + + // Field order: 发件人 < 时间 < 主题 < 收件人 < 抄送 + assertFieldOrder(t, html, []string{`发件人: `, `时间: `, `主题: `, `收件人: `, `抄送: `}) +} + +func TestBuildReplyQuoteHTMLEnglishLabels(t *testing.T) { + orig := sampleEnglishOriginalMessage() + html := buildReplyQuoteHTML(&orig) + + mustContain(t, html, `From: `) + mustContain(t, html, `Date: `) + mustContain(t, html, `Subject: `) + mustContain(t, html, `To: `) + mustContain(t, html, `Cc: `) + mustNotContain(t, html, `发件人`) +} + +func TestBuildForwardQuoteHTMLStructure(t *testing.T) { + orig := sampleOriginalMessage() // subject contains Chinese + html := buildForwardQuoteHTML(&orig) + + mustContain(t, html, `class="history-quote-wrapper"`) + mustContain(t, html, `class="adit-html-block adit-html-block--header"`) + mustContain(t, html, `style="border-left: none; padding-left: 0px;"`) + + // Forward meta wrapper class (not adit-html-block__attr) + mustContain(t, html, `class="adit-html-block__header history-quote-meta-after-forward-title history-quote-meta-wrapper"`) + mustNotContain(t, html, `class="adit-html-block__attr`) + + // separator has margin-top: 24px; meta block has margin-top: 2px + mustContain(t, html, `margin-top: 24px`) + mustContain(t, html, `margin-top: 2px`) + + // Separator is outside meta wrapper, uses correct class + mustContain(t, html, `class="history-quote-forward-title lme-line-signal history-quote-gap-tag"`) + mustContain(t, html, `--------- 转发消息 ---------`) // Chinese because subject has CJK + + // Chinese labels + mustContain(t, html, `发件人: `) + mustContain(t, html, `时间: `) + mustContain(t, html, `主题: `) + mustContain(t, html, `收件人: `) + mustContain(t, html, `抄送: `) + + // IDs present with lark-mail-quote-cli prefix + mustContain(t, html, `id="lark-mail-quote-cli`) + mustContain(t, html, `id="lark-mail-meta-cli`) + + // Separator before meta block + sepIdx := strings.Index(html, `--------- 转发消息 ---------`) + metaIdx := strings.Index(html, `adit-html-block__header history-quote-meta-after-forward-title`) + if sepIdx > metaIdx { + t.Fatalf("separator should appear before meta block: sep=%d meta=%d", sepIdx, metaIdx) + } + + // Field order + assertFieldOrder(t, html, []string{`发件人: `, `时间: `, `主题: `, `收件人: `, `抄送: `}) +} + +func TestBuildForwardQuoteHTMLEnglishSeparator(t *testing.T) { + orig := sampleEnglishOriginalMessage() + html := buildForwardQuoteHTML(&orig) + + mustContain(t, html, `---------- Forwarded message ---------`) + mustNotContain(t, html, `转发消息`) + mustContain(t, html, `From: `) +} + +func TestBuildReplyPrefixHTMLNoCcWhenEmpty(t *testing.T) { + orig := sampleOriginalMessage() + orig.ccAddresses = nil + orig.ccAddressesFull = nil + prefix := buildReplyPrefixHTML(&orig) + mustNotContain(t, prefix, `抄送: `) + mustNotContain(t, prefix, `Cc: `) +} + +func TestBuildAddressHTMLWithName(t *testing.T) { + html := buildAddressHTML("Alice", "alice@example.com") + mustContain(t, html, `"Alice"<`) + mustContain(t, html, `>`) + mustContain(t, html, `href="mailto:alice@example.com"`) +} + +func TestBuildAddressHTMLWithoutName(t *testing.T) { + html := buildAddressHTML("", "alice@example.com") + mustContain(t, html, `<= b { + t.Fatalf("expected %q (pos %d) before %q (pos %d)", fields[i-1], a, fields[i], b) + } + _ = prev + prev = a + } +} + +func mustContain(t *testing.T, s, sub string) { + t.Helper() + if !strings.Contains(s, sub) { + t.Fatalf("expected content to contain %q, got: %s", sub, s) + } +} + +func mustNotContain(t *testing.T, s, sub string) { + t.Helper() + if strings.Contains(s, sub) { + t.Fatalf("expected content to not contain %q, got: %s", sub, s) + } +} + +// --------------------------------------------------------------------------- +// Plain-text quote format tests +// --------------------------------------------------------------------------- + +func TestQuoteForReplyPlainTextChineseMeta(t *testing.T) { + orig := sampleOriginalMessage() // subject contains Chinese + quote := quoteForReply(&orig, false) + + // Should start with two newlines + if !strings.HasPrefix(quote, "\n\n") { + t.Fatalf("expected quote to start with \\n\\n, got: %q", quote[:20]) + } + + // Chinese labels because subject has CJK + mustContain(t, quote, "> 发件人:") + mustContain(t, quote, "> 主题:") + mustContain(t, quote, "> 时间:") + mustContain(t, quote, "> 收件人:") + mustContain(t, quote, "> 抄送:") + + // Blank separator line before body + mustContain(t, quote, ">\n") + + // Body lines should be prefixed with "> " + mustContain(t, quote, "> hello world\n") +} + +func TestQuoteForReplyPlainTextEnglishMeta(t *testing.T) { + orig := sampleEnglishOriginalMessage() + quote := quoteForReply(&orig, false) + + // English labels + mustContain(t, quote, "> From: ") + mustContain(t, quote, "> Subject: ") + mustContain(t, quote, "> Date: ") + mustContain(t, quote, "> To: ") + mustContain(t, quote, "> Cc: ") + + // Should not contain Chinese labels + mustNotContain(t, quote, "发件人") + mustNotContain(t, quote, "主题") +} + +func TestQuoteForReplyPlainTextNoCc(t *testing.T) { + orig := sampleOriginalMessage() + orig.ccAddresses = nil + orig.ccAddressesFull = nil + quote := quoteForReply(&orig, false) + + // Should not contain Cc line when empty + mustNotContain(t, quote, "> 抄送:") + mustNotContain(t, quote, "> Cc: ") +} + +func TestQuoteForReplyPlainTextFieldOrder(t *testing.T) { + orig := sampleOriginalMessage() + quote := quoteForReply(&orig, false) + + // Field order should match HTML: From, Date, Subject, To, Cc + assertFieldOrder(t, quote, []string{ + "> 发件人:", + "> 时间:", + "> 主题:", + "> 收件人:", + "> 抄送:", + }) +} + +func TestBuildReplyMetaPlainTextAddressFormat(t *testing.T) { + orig := originalMessage{ + subject: "Test", + headFrom: "alice@example.com", + headFromName: "Alice", + headDate: "Mon, 01 Jan 2026 12:00:00 +0000", + toAddressesFull: []mailAddressPair{{Email: "bob@example.com", Name: "Bob"}}, + bodyRaw: "Hello", + } + quote := quoteForReply(&orig, false) + + // Address format should be "Name" + mustContain(t, quote, `> From: "Alice" `) + mustContain(t, quote, `> To: "Bob" `) +} + +func TestBuildReplyMetaPlainTextAddressWithoutName(t *testing.T) { + orig := originalMessage{ + subject: "Test", + headFrom: "alice@example.com", + headFromName: "", + headDate: "Mon, 01 Jan 2026 12:00:00 +0000", + bodyRaw: "Hello", + } + quote := quoteForReply(&orig, false) + + // Without name, should just be + mustContain(t, quote, `> From: `) + mustNotContain(t, quote, `"" <`) +} + +// --------------------------------------------------------------------------- +// bodyIsHTML tests +// --------------------------------------------------------------------------- + +func TestBodyIsHTML(t *testing.T) { + tests := []struct { + name string + in string + want bool + }{ + // Should detect as HTML + {"div tag", "
hello
", true}, + {"div with attrs", `
hello
`, true}, + {"p tag", "

paragraph

", true}, + {"br self-close", "line1
line2", true}, + {"br space-close", "line1
line2", true}, + {"br angle", "line1
line2", true}, + {"html tag", "hi", true}, + {"doctype", "", true}, + {"doctype lower", "", true}, + {"comment", "", true}, + {"img", ``, true}, + {"table", "
1
", true}, + {"span", "text", true}, + {"h1", "

Title

", true}, + {"strong", "bold", true}, + {"blockquote", "
quoted
", true}, + {"pre", "
code
", true}, + {"hr", "
", true}, + {"case insensitive", "
hello
", true}, + {"mixed case", "
hello
", true}, + + // Should NOT detect as HTML (false positive prevention) + {"plain text", "hello world", false}, + {"angle brackets math", "price < 100 & qty > 50", false}, + {"filename in angles", "see for details", false}, + {"brand tag-like", "the is strong", false}, + {"email angle", "contact ", false}, + {"empty", "", false}, + {"just angle", "<", false}, + {"unclosed angle", "content", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := bodyIsHTML(tc.in); got != tc.want { + t.Errorf("bodyIsHTML(%q) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} + +func TestBuildBodyDivEmpty(t *testing.T) { + if got := buildBodyDiv("", false); got != "" { + t.Fatalf("expected empty, got %q", got) + } + if got := buildBodyDiv("", true); got != "" { + t.Fatalf("expected empty, got %q", got) + } +} + +func TestBuildBodyDivPlainTextNewlines(t *testing.T) { + got := buildBodyDiv("line1\nline2\nline3", false) + mustContain(t, got, "line1
line2
line3") + mustNotContain(t, got, "\n") +} + +func TestBuildBodyDivPlainTextEscapesHTML(t *testing.T) { + got := buildBodyDiv("", false) + mustNotContain(t, got, "