From 166fcd8613353533ec838a331eddbe2d94120d0f Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Fri, 23 Oct 2026 13:23:19 +0800 Subject: [PATCH] feat(sdk): complete action parameter and result types --- AGENTS.md | 2 +- .../onebot/tests/sdk-action-params.test.ts | 31 ++ packages/sdk/CHANGELOG.md | 2 + packages/sdk/VERSIONING.md | 6 +- packages/sdk/src/types/actions.ts | 158 +++++++ packages/sdk/src/types/params.ts | 241 +++++++++++ packages/sdk/src/types/results.ts | 383 +++++++++++++++++ .../sdk/tests/fixtures/action-contracts.ts | 397 ++++++++++++++++++ packages/sdk/tests/fixtures/action-types.mts | 136 ++++++ packages/sdk/tests/package.test.ts | 9 + 10 files changed, 1363 insertions(+), 2 deletions(-) create mode 100644 packages/onebot/tests/sdk-action-params.test.ts create mode 100644 packages/sdk/tests/fixtures/action-contracts.ts create mode 100644 packages/sdk/tests/fixtures/action-types.mts diff --git a/AGENTS.md b/AGENTS.md index 623661e2..0be9a19c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Use focused tests while iterating. Before handing off a code change, run the ful - **Documentation-only changes:** verify links, paths, commands and `git diff --check`; runtime tests do not validate prose. - **Shared types or actions:** run workspace typechecking as well as behavior tests. A passing Vitest run does not prove type correctness. -- **SDK packaging:** keep the SDK's `NodeNext` build and explicit `.js` relative imports, including type-only exports. Its package tests compile fresh output and check native Node.js imports and consumer declarations. The SDK action catalog must match OneBot's executable names; `packages/onebot/tests/sdk-actions.test.ts` checks this without adding a runtime dependency on server code. +- **SDK packaging:** keep the SDK's `NodeNext` build and explicit `.js` relative imports, including type-only exports. Its package tests compile fresh output and check native Node.js imports, consumer declarations, and complete action-map coverage. The SDK action catalog must match OneBot's executable names; `packages/onebot/tests/sdk-actions.test.ts` checks this without adding a runtime dependency on server code. SDK parameter/result contracts remain independent of server types; keep consumer examples under `packages/sdk/tests/fixtures` aligned with the server parser checks in `packages/onebot/tests/sdk-action-params.test.ts`. - **Protocol codecs:** keep the existing Proton integration in tests so they exercise the same transformations as production. Import real implementations; do not duplicate the algorithm in the test. - **Native coverage:** `pnpm test:native` is separate from default protocol tests and requires compatible native artifacts. A missing artifact or unavailable target is a reported limitation, not permission to bypass the check. - **Live QQ behavior:** fixture tests and a successful API response do not prove delivery or client rendering. State what was tested locally and what still needs a real client. diff --git a/packages/onebot/tests/sdk-action-params.test.ts b/packages/onebot/tests/sdk-action-params.test.ts new file mode 100644 index 00000000..b750ab4b --- /dev/null +++ b/packages/onebot/tests/sdk-action-params.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { actionParams } from '../../sdk/tests/fixtures/action-contracts'; +import { actions as extended } from '../src/actions/extended'; +import { actions as albums } from '../src/actions/group-album'; +import { actions as groupAdmin } from '../src/actions/group-admin'; +import { actions as qzone } from '../src/actions/qzone'; +import { actions as streamDownload } from '../src/actions/stream-download'; +import { actions as streamFile } from '../src/actions/stream-file'; + +const actions = [...extended, ...albums, ...groupAdmin, ...qzone, ...streamDownload, ...streamFile]; +const examples = Object.entries(actionParams).filter( + ([name]) => name !== '.handle_quick_operation_async', +); + +describe('SDK action parameter examples', () => { + it.each(examples)('%s matches the server input contract', (name, params) => { + const action = actions.find((entry) => entry.names.includes(name)); + + if (!action) { + throw new Error(`No server action for SDK example ${name}`); + } + + expect(Object.keys(params).sort()).toEqual( + action + .describe() + .params.map(({ name }) => name) + .sort(), + ); + expect(action.parse(params)).toMatchObject({ ok: true }); + }); +}); diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index bb07adee..5f4c65e3 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add parameter and result types for the remaining 57 actions and three aliases, covering every registered action name while preserving existing client methods and type mappings. +- Describe album pagination and media, Qzone, flash transfers, stickers, group settings, and stream frame payloads; retain numeric-string inputs and signed message IDs. - Fix native Node.js ESM imports for the package root and all supported subpaths without requiring a bundler. - Restore `@snowluma/sdk/actions` and the root exports `SNOWLUMA_ACTIONS`, `isSnowLumaAction`, and `SnowLumaKnownAction`, including current action names and compatibility aliases. - Require patched Node.js releases: 24.22.1 or newer in the 24.x line, or 26.12.1 and newer. diff --git a/packages/sdk/VERSIONING.md b/packages/sdk/VERSIONING.md index 78519083..e2075b16 100644 --- a/packages/sdk/VERSIONING.md +++ b/packages/sdk/VERSIONING.md @@ -41,4 +41,8 @@ If the package version already exists on npm, the workflow skips publishing that `SNOWLUMA_ACTIONS`, `isSnowLumaAction`, and `SnowLumaKnownAction` are available from both `@snowluma/sdk` and `@snowluma/sdk/actions`. The catalog includes this release's executable action names, compatibility aliases, and raw actions. OneBot's registry tests verify that the SDK catalog stays aligned with the server; the published SDK does not import server code. -This local catalog is not a capability check against a connected server. Detailed parameter and result types remain in `SnowLumaActionMap`; knowing an action name does not imply it has a specialized type mapping. Custom actions remain supported through `raw()`. Adding an action type is a minor change unless it changes an existing method signature or return type. +This local catalog is not a capability check against a connected server. Every known action name has a parameter and result entry in `SnowLumaActionMap`, including aliases. Existing broad JSON contracts are retained for compatibility. New parameter types describe accepted input forms, including numeric strings and primitive coercions; server-side range and state validation still applies. Custom actions and dynamically selected names remain supported through `raw()` and `rawResponse()`. Adding an action type is a minor change unless it changes an existing method signature or return type. + +Stream result types describe individual frame payloads, including intermediate and terminal frames. These declarations do not add streaming transport support to the single-response HTTP and WebSocket clients; applications consuming a full download must use a transport that reads every frame. Stream reset/error envelopes retain their existing error behavior. Album counts and IDs returned as decimal strings stay strings, and ordinary message IDs remain signed numbers. + +The package consumer tests check complete action-map coverage, inferred results, compatibility aliases, and invalid input examples with both NodeNext and bundler resolution. The same parameter examples are checked against the server's real action parsers without starting QQ or executing actions. diff --git a/packages/sdk/src/types/actions.ts b/packages/sdk/src/types/actions.ts index 6146a3e5..01f4c75b 100644 --- a/packages/sdk/src/types/actions.ts +++ b/packages/sdk/src/types/actions.ts @@ -79,6 +79,46 @@ import type { SendPacketParams, SearchSysFacesParams, GetQzoneMsgDetailParams, + ActionGroupIdParams, + ActionMessageIdParams, + ActionUserIdParams, + AddCustomFaceParams, + CommentQzoneParams, + CreateFlashTaskParams, + CustomFaceIdParams, + DownloadFileStreamParams, + DownloadFilesetParams, + FetchCustomFaceDetailParams, + FilesetIdParams, + GetDoubtFriendsAddRequestParams, + GetFilesetIdParams, + GetFlashFileUrlParams, + GetGroupAlbumMediaListParams, + GetQunAlbumListParams, + GetQzoneFeedsParams, + GetQzoneMsgListParams, + GroupAlbumCommentParams, + GroupAlbumMediaLikeParams, + GroupAlbumMediaParams, + LikeQzoneParams, + ModifyCustomFaceParams, + QzoneMsgIdParams, + RenameFlashFileParams, + RequestDecryptKeyParams, + SendFlashMsgParams, + SendQzoneMsgParams, + SendTuwenArkParams, + SetDoubtFriendsAddRequestParams, + SetGroupMemberInvitePolicyParams, + SetGroupMemberPermissionsParams, + SetGroupNewMemberHistoryVisibilityParams, + SetGroupRobotAddOptionParams, + SetQzoneBanParams, + SetQzoneMsgRightParams, + SharePeerParams, + TestDownloadStreamParams, + UploadFileStreamParams, + UploadGroupAlbumMediaParams, } from './params.js'; import type { CapabilityInfo, @@ -117,6 +157,39 @@ import type { SystemFaceInfo, SystemFaceSearchInfo, QzoneMsgDetailResult, + AddCustomFaceResult, + ArkShareResult, + CleanStreamTempFileResult, + CreateFlashTaskResult, + CustomFaceDetail, + DecryptKeyResult, + DeleteGroupAlbumMediaResult, + DoubtFriendsAddRequest, + DownloadFileStreamResult, + DownloadFilesetResult, + FilesetIdResult, + FilesetInfo, + FlashFileInfo, + FlashFileUrl, + FriendDressResult, + GroupAlbumCommentResult, + GroupAlbumInfo, + GroupAlbumMediaLikeResult, + GroupAlbumMediaResult, + GroupSignedMember, + MessageEmojiLikeInfo, + PttTextResult, + QunAlbumListResult, + QzoneCommentResult, + QzoneFeedsResult, + QzoneMsgListResult, + QzonePublishResult, + QzoneUpdateRightResult, + RkeyServerResult, + SendFlashMsgResult, + TestDownloadStreamResult, + UploadFileStreamResult, + UploadGroupAlbumVideoResult, } from './results.js'; export type ActionData = JsonValue; @@ -276,6 +349,91 @@ export interface SnowLumaActionMap { get_ai_characters: { params: GetAiCharactersParams; data: AiCharacterCategory[] }; get_ai_record: { params: AiVoiceParams; data: string }; send_group_ai_record: { params: AiVoiceParams; data: SendGroupAiRecordResult }; + // Additional executable actions and compatibility aliases. + _get_friend_dress: { params: ActionUserIdParams; data: FriendDressResult }; + add_custom_face: { params: AddCustomFaceParams; data: AddCustomFaceResult }; + cancel_group_album_media_like: { + params: GroupAlbumMediaLikeParams; + data: GroupAlbumMediaLikeResult; + }; + clean_stream_temp_file: { params: JsonObject; data: CleanStreamTempFileResult }; + comment_qzone: { params: CommentQzoneParams; data: QzoneCommentResult }; + create_flash_task: { params: CreateFlashTaskParams; data: CreateFlashTaskResult }; + del_group_album_media: { params: GroupAlbumMediaParams; data: DeleteGroupAlbumMediaResult }; + delete_custom_face: { params: CustomFaceIdParams; data: EmptyData }; + delete_flash_file: { params: FilesetIdParams; data: EmptyData }; + delete_qzone_msg: { params: QzoneMsgIdParams; data: EmptyData }; + do_group_album_comment: { params: GroupAlbumCommentParams; data: GroupAlbumCommentResult }; + download_file_image_stream: { + params: DownloadFileStreamParams; + data: DownloadFileStreamResult; + }; + download_file_record_stream: { + params: DownloadFileStreamParams; + data: DownloadFileStreamResult; + }; + download_file_stream: { params: DownloadFileStreamParams; data: DownloadFileStreamResult }; + download_fileset: { params: DownloadFilesetParams; data: DownloadFilesetResult }; + fetch_custom_face_detail: { params: FetchCustomFaceDetailParams; data: CustomFaceDetail[] }; + fetch_ptt_text: { params: ActionMessageIdParams; data: PttTextResult }; + get_ptt_text: { params: ActionMessageIdParams; data: PttTextResult }; + get_record_text: { params: ActionMessageIdParams; data: PttTextResult }; + get_doubt_friends_add_request: { + params: GetDoubtFriendsAddRequestParams; + data: DoubtFriendsAddRequest[]; + }; + get_fileset_id: { params: GetFilesetIdParams; data: FilesetIdResult }; + get_fileset_info: { params: FilesetIdParams; data: FilesetInfo }; + get_flash_file_list: { params: FilesetIdParams; data: FlashFileInfo[] }; + get_flash_file_url: { params: GetFlashFileUrlParams; data: FlashFileUrl }; + get_group_album_list: { params: ActionGroupIdParams; data: GroupAlbumInfo[] }; + get_group_album_media_list: { + params: GetGroupAlbumMediaListParams; + data: GroupAlbumMediaResult; + }; + get_group_signed_list: { params: ActionGroupIdParams; data: GroupSignedMember[] }; + get_msg_emoji_likes: { params: ActionMessageIdParams; data: MessageEmojiLikeInfo[] }; + get_qun_album_list: { params: GetQunAlbumListParams; data: QunAlbumListResult }; + get_qzone_feeds: { params: GetQzoneFeedsParams; data: QzoneFeedsResult }; + get_qzone_msg_list: { params: GetQzoneMsgListParams; data: QzoneMsgListResult }; + get_rkey_server: { params: JsonObject; data: RkeyServerResult }; + get_share_link: { params: FilesetIdParams; data: string }; + like_qzone: { params: LikeQzoneParams; data: EmptyData }; + list_filesets: { params: JsonObject; data: FlashFileInfo[] }; + modify_custom_face: { params: ModifyCustomFaceParams; data: EmptyData }; + move_custom_face_to_front: { params: CustomFaceIdParams; data: EmptyData }; + rename_flash_file: { params: RenameFlashFileParams; data: EmptyData }; + request_decrypt_key: { params: RequestDecryptKeyParams; data: DecryptKeyResult }; + send_ark_share: { params: SharePeerParams; data: ArkShareResult }; + send_flash_msg: { params: SendFlashMsgParams; data: SendFlashMsgResult }; + send_group_ark_share: { params: ActionGroupIdParams; data: string }; + send_qzone_msg: { params: SendQzoneMsgParams; data: QzonePublishResult }; + send_tuwen_ark: { params: SendTuwenArkParams; data: EmptyData }; + set_doubt_friends_add_request: { params: SetDoubtFriendsAddRequestParams; data: EmptyData }; + set_group_album_media_like: { + params: GroupAlbumMediaLikeParams; + data: GroupAlbumMediaLikeResult; + }; + set_group_member_invite_policy: { params: SetGroupMemberInvitePolicyParams; data: EmptyData }; + set_group_member_permissions: { params: SetGroupMemberPermissionsParams; data: EmptyData }; + set_group_new_member_history_visibility: { + params: SetGroupNewMemberHistoryVisibilityParams; + data: EmptyData; + }; + set_group_robot_add_option: { params: SetGroupRobotAddOptionParams; data: EmptyData }; + set_qzone_ban: { params: SetQzoneBanParams; data: EmptyData }; + set_qzone_msg_right: { params: SetQzoneMsgRightParams; data: QzoneUpdateRightResult }; + share_group_ex: { params: ActionGroupIdParams; data: string }; + share_peer: { params: SharePeerParams; data: ArkShareResult }; + test_download_stream: { params: TestDownloadStreamParams; data: TestDownloadStreamResult }; + unlike_qzone: { params: LikeQzoneParams; data: EmptyData }; + upload_file_stream: { params: UploadFileStreamParams; data: UploadFileStreamResult }; + upload_image_to_qun_album: { params: UploadGroupAlbumMediaParams; data: EmptyData }; + upload_video_to_qun_album: { + params: UploadGroupAlbumMediaParams; + data: UploadGroupAlbumVideoResult; + }; + '.handle_quick_operation_async': { params: QuickOperationParams; data: EmptyData }; } export type SnowLumaAction = keyof SnowLumaActionMap | (string & {}); diff --git a/packages/sdk/src/types/params.ts b/packages/sdk/src/types/params.ts index ed336346..513a76ce 100644 --- a/packages/sdk/src/types/params.ts +++ b/packages/sdk/src/types/params.ts @@ -433,3 +433,244 @@ export interface GetQzoneMsgDetailParams extends JsonObject { /** Requested page size, 1–100 (default 20); Qzone may return fewer. */ num?: number | string; } + +/** Numeric action fields also accept numeric strings; bounds are checked by the server. */ +export type ActionNumberInput = number | string; +/** String action fields preserve the server's number/boolean coercion. */ +export type ActionStringInput = string | number | boolean; +/** Boolean fields accept numbers and strings such as true/false, yes/no and on/off. */ +export type ActionBooleanInput = boolean | number | string; + +export interface ActionGroupIdParams extends JsonObject { + group_id: ActionNumberInput; +} + +export interface ActionUserIdParams extends JsonObject { + user_id: ActionNumberInput; +} + +export interface ActionMessageIdParams extends JsonObject { + /** Non-zero signed message ID; negative values are valid. */ + message_id: ActionNumberInput; +} + +/** Both IDs may be supplied; these actions give group_id precedence. */ +export type ActionPeerParams = JsonObject & + ( + | { group_id: ActionNumberInput; user_id?: ActionNumberInput } + | { user_id: ActionNumberInput; group_id?: ActionNumberInput } + ); + +export interface AddCustomFaceParams extends JsonObject { + file: ActionStringInput; +} + +export interface CustomFaceIdParams extends JsonObject { + emoji_id: ActionStringInput; +} + +export interface ModifyCustomFaceParams extends CustomFaceIdParams { + desc?: ActionStringInput; +} + +export interface FetchCustomFaceDetailParams extends JsonObject { + count?: ActionNumberInput; +} + +export interface GetDoubtFriendsAddRequestParams extends JsonObject { + count?: ActionNumberInput; +} + +export interface SetDoubtFriendsAddRequestParams extends JsonObject { + flag: ActionStringInput; + approve?: ActionBooleanInput; +} + +export type SharePeerParams = ActionPeerParams & { phone_number?: ActionStringInput }; + +export type SendTuwenArkParams = ActionPeerParams & { + title: ActionStringInput; + desc: ActionStringInput; + summary?: ActionStringInput; + preview_url?: ActionStringInput; + jump_url: ActionStringInput; +}; + +export interface RequestDecryptKeyParams extends JsonObject { + db_path: ActionStringInput; +} + +export interface SetGroupMemberInvitePolicyParams extends ActionGroupIdParams { + policy: 'disabled' | 'require_approval' | 'no_approval' | 'no_approval_under_100'; +} + +export interface SetGroupNewMemberHistoryVisibilityParams extends ActionGroupIdParams { + visible: ActionBooleanInput; +} + +export type SetGroupMemberPermissionsParams = ActionGroupIdParams & + ( + | { + allow_member_upload_album: ActionBooleanInput; + allow_member_temporary_session?: ActionBooleanInput; + allow_member_create_group?: ActionBooleanInput; + } + | { + allow_member_upload_album?: ActionBooleanInput; + allow_member_temporary_session: ActionBooleanInput; + allow_member_create_group?: ActionBooleanInput; + } + | { + allow_member_upload_album?: ActionBooleanInput; + allow_member_temporary_session?: ActionBooleanInput; + allow_member_create_group: ActionBooleanInput; + } + ); + +export interface SetGroupRobotAddOptionParams extends ActionGroupIdParams { + robot_member_switch?: ActionNumberInput; + robot_member_examine?: ActionNumberInput; +} + +export interface GetQunAlbumListParams extends ActionGroupIdParams { + attach_info?: ActionStringInput; +} + +export interface GroupAlbumParams extends ActionGroupIdParams { + album_id: ActionStringInput; +} + +export interface UploadGroupAlbumMediaParams extends GroupAlbumParams { + album_name: ActionStringInput; + file: ActionStringInput; +} + +export interface GetGroupAlbumMediaListParams extends GroupAlbumParams { + attach_info?: ActionStringInput; +} + +export interface GroupAlbumMediaParams extends GroupAlbumParams { + /** Photo locator or video ID. */ + lloc: ActionStringInput; +} + +export interface GroupAlbumCommentParams extends GroupAlbumMediaParams { + content: ActionStringInput; +} + +export interface GroupAlbumMediaLikeParams extends GroupAlbumParams { + batch_id: ActionStringInput; + lloc?: ActionStringInput; +} + +export interface GetQzoneMsgListParams extends JsonObject { + target_uin?: ActionNumberInput; + pos?: ActionNumberInput; + num?: ActionNumberInput; +} + +export interface GetQzoneFeedsParams extends JsonObject { + /** Page number starts at 1; only the first page is reliable upstream. */ + page_num?: ActionNumberInput; + count?: ActionNumberInput; +} + +export interface QzoneMsgIdParams extends JsonObject { + tid: ActionStringInput; +} + +/** 1=public, 4=friends, 16=selected friends, 64=self, 128=exclude selected friends. */ +export type QzoneUgcRight = 1 | 4 | 16 | 64 | 128; + +export interface SendQzoneMsgParams extends JsonObject { + content: ActionStringInput; + images?: ActionStringInput[]; + /** Defaults to 1. Values are validated as QzoneUgcRight by the server. */ + ugc_right?: ActionNumberInput; + /** Required and non-empty when ugc_right is 16 or 128. */ + target_uins?: ActionNumberInput[]; +} + +export interface SetQzoneMsgRightParams extends QzoneMsgIdParams { + /** One of the QzoneUgcRight values, also accepted as a numeric string. */ + ugc_right: ActionNumberInput; + /** Required and non-empty when ugc_right is 16 or 128. */ + target_uins?: ActionNumberInput[]; +} + +export interface LikeQzoneParams extends QzoneMsgIdParams { + target_uin?: ActionNumberInput; + /** Post publication time in Unix seconds. */ + abstime?: ActionNumberInput; +} + +export interface CommentQzoneParams extends QzoneMsgIdParams { + content: ActionStringInput; + target_uin?: ActionNumberInput; + images?: ActionStringInput[]; +} + +export interface SetQzoneBanParams extends ActionUserIdParams { + enable?: ActionBooleanInput; +} + +export type FlashTaskFile = string | { file: string; name?: string }; + +export interface CreateFlashTaskParams extends JsonObject { + /** A path, a named file, or a non-empty array mixing both forms. */ + files: FlashTaskFile | FlashTaskFile[]; + name?: ActionStringInput; + thumb_path?: ActionStringInput; +} + +export interface FilesetIdParams extends JsonObject { + fileset_id: ActionStringInput; +} + +export interface GetFlashFileUrlParams extends FilesetIdParams { + /** Accepted for compatibility; this action selects by file_index. */ + file_name?: ActionStringInput; + file_index?: ActionNumberInput; +} + +export interface DownloadFilesetParams extends FilesetIdParams { + file_name?: ActionStringInput; + file_index?: ActionNumberInput; +} + +export interface RenameFlashFileParams extends FilesetIdParams { + new_name: ActionStringInput; +} + +export type SendFlashMsgParams = FilesetIdParams & ActionPeerParams; + +export interface GetFilesetIdParams extends JsonObject { + share_code: ActionStringInput; +} + +export interface UploadFileStreamParams extends JsonObject { + stream_id: ActionStringInput; + chunk_data?: ActionStringInput; + chunk_index?: ActionNumberInput; + /** Required when creating a stream; omitted for status, reset and later chunks. */ + total_chunks?: ActionNumberInput; + file_size?: ActionNumberInput; + expected_sha256?: ActionStringInput; + is_complete?: ActionBooleanInput; + filename?: ActionStringInput; + reset?: ActionBooleanInput; + verify_only?: ActionBooleanInput; + /** Retention in milliseconds; 0 disables automatic expiry. */ + file_retention?: ActionNumberInput; +} + +export type DownloadFileStreamParams = JsonObject & { + chunk_size?: ActionNumberInput; +} & ( + | { file: ActionStringInput; file_id?: ActionStringInput } + | { file_id: ActionStringInput; file?: ActionStringInput } + ); + +export interface TestDownloadStreamParams extends JsonObject { + error?: ActionBooleanInput; +} diff --git a/packages/sdk/src/types/results.ts b/packages/sdk/src/types/results.ts index 6d6c6819..e0c29061 100644 --- a/packages/sdk/src/types/results.ts +++ b/packages/sdk/src/types/results.ts @@ -301,3 +301,386 @@ export interface UserOnlineStatus { export interface SendGroupAiRecordResult { message_id: number; } + +export interface CustomFaceDetail extends JsonObject { + emoji_id: string; + resId: string; + url: string; + md5: string; + desc: string; +} + +export interface AddCustomFaceResult extends JsonObject { + emoji_id: string; +} + +export interface PttTextResult extends JsonObject { + text: string; +} + +export interface MessageEmojiLikeInfo extends JsonObject { + emoji_id: string; + emoji_type: number; + count: number; + last_reaction_time: number; + users: Array<{ user_id: number }>; +} + +export interface GroupSignedMember extends JsonObject { + user_id: number; + nick: string; + time: number; + rank: number; +} + +export interface DoubtFriendsAddRequest extends JsonObject { + /** Pass this UID back as the request's flag. */ + uid: string; + user_id: number; + nick: string; + source: string; + reason: string; + msg: string; + group_code: string; + reqTime: number; +} + +export interface FriendDressItem extends JsonObject { + app_id: number; + kind: string; + item_id: number; + name: string; + preview_url: string; + video_url: string; + price: number; +} + +export interface FriendDressResult extends JsonObject { + target_uin: string; + is_svip: boolean; + avatar_url: string; + items: FriendDressItem[]; +} + +export interface ArkShareResult extends JsonObject { + arkMsg: string; +} + +export interface DecryptKeyResult extends JsonObject { + db_key: string; +} + +export interface RkeyServerResult extends JsonObject { + /** Unix seconds; either key can be absent. */ + expired_time: number; + name: string; + private_rkey?: string; + group_rkey?: string; +} + +export interface QzoneEmotion extends JsonObject { + tid: string; + content: string; + /** Unix seconds. */ + time: number; + comment_num: number; + is_private: boolean; + images: string[]; +} + +export interface QzoneMsgListResult extends JsonObject { + total: number; + msglist: QzoneEmotion[]; +} + +export interface QzoneFeed extends JsonObject { + uin: number; + nickname: string; + time: number; + appid: number; + key: string; + html: string; +} + +export interface QzoneFeedsResult extends JsonObject { + feeds: QzoneFeed[]; + has_more: boolean; +} + +export interface QzonePublishResult extends JsonObject { + tid: string; + time: number; +} + +export interface QzoneCommentResult extends JsonObject { + /** Empty string when the upstream response omits the new comment ID. */ + comment_id: string; +} + +export interface QzoneUpdateRightResult extends JsonObject { + ugc_right: number; +} + +export interface QunAlbumCoverUrl extends JsonObject { + url: string; + width: number; + height: number; +} + +export interface QunAlbumCoverPhotoUrl extends JsonObject { + spec: number; + url: QunAlbumCoverUrl | null; +} + +export interface QunAlbumCoverImage extends JsonObject { + name: string; + sloc: string; + lloc: string; + photoUrls: QunAlbumCoverPhotoUrl[]; + defaultUrl: QunAlbumCoverUrl | null; + isGif: boolean; + hasRaw: boolean; +} + +export interface QunAlbumCover extends JsonObject { + type: number; + image: QunAlbumCoverImage | null; +} + +export interface GroupAlbumInfo extends JsonObject { + id: string; + name: string; + picNum: number; + createTime: number; + desc: string; + owner: string; + createuin: string; + createnickname: string; + last_upload_time: number; + cover: QunAlbumCover | null; +} + +export interface QunAlbumCreator extends JsonObject { + uid: string; + nick: string; + is_sweet: boolean; + is_special: boolean; + is_super_like: boolean; + custom_id: string; + poly_id: string; + portrait: string; + can_follow: number; + isfollowed: number; + uin: string; + ditto_uin: string; +} + +export interface QunAlbumInfo extends JsonObject { + album_id: string; + owner: string; + name: string; + desc: string; + /** The Qun API preserves 64-bit times and counts as decimal strings. */ + create_time: string; + modify_time: string; + last_upload_time: string; + upload_number: string; + cover: QunAlbumCover | null; + creator?: QunAlbumCreator; + top_flag: string; + busi_type: number; + status: number; + allow_share: boolean; + is_subscribe: boolean; + bitmap: string; + is_share_album: boolean; + qz_album_type: number; + cover_type: number; + default_desc: string; + sort_type: number; +} + +export interface QunAlbumListResult extends JsonObject { + album_list: QunAlbumInfo[]; + attach_info: string; + has_more: boolean; +} + +// Media-list fields are passed through from QQ, unlike normalized album covers. +// Omitted fields stay optional; explicitly undefined decoded values become null. +export interface GroupAlbumMediaUrl extends JsonObject { + url?: string | null; + width?: number | null; + height?: number | null; +} + +export interface GroupAlbumMediaPhotoUrl extends JsonObject { + spec?: number | null; + url?: GroupAlbumMediaUrl | null; +} + +export interface GroupAlbumMediaImage extends JsonObject { + name?: string | null; + sloc?: string | null; + lloc?: string | null; + photoUrls?: GroupAlbumMediaPhotoUrl[] | null; + defaultUrl?: GroupAlbumMediaUrl | null; + isGif?: boolean | null; + hasRaw?: boolean | null; +} + +export interface GroupAlbumMediaVideo extends JsonObject { + id?: string | null; + url?: string | null; + cover?: GroupAlbumMediaImage | null; + width?: number | null; + height?: number | null; + videoTime?: string | null; + videoUrl?: GroupAlbumMediaPhotoUrl[] | null; +} + +export interface GroupAlbumMediaInfo extends JsonObject { + type?: number | null; + image?: GroupAlbumMediaImage | null; + video?: GroupAlbumMediaVideo | null; + uploader?: string | null; + batchId?: string | null; + uploadTime?: string | null; +} + +export interface GroupAlbumMediaResult extends JsonObject { + mediaList: GroupAlbumMediaInfo[]; + nextAttachInfo: string; +} + +export interface GroupAlbumCommentResult extends JsonObject { + id: string; + user: { uin: string }; + content: Array<{ type: number; content: string }>; + time: string; + clientKey: string; +} + +export interface GroupAlbumMediaLikeResult extends JsonObject { + id?: string | null; + status?: number | null; +} + +export interface UploadGroupAlbumVideoResult extends JsonObject { + id: string; +} + +export interface DeleteGroupAlbumMediaResult extends JsonObject { + success: true; +} + +export interface CreateFlashTaskResult extends JsonObject { + fileset_id: string; + task_id: string; +} + +export interface FlashFileInfo extends JsonObject { + fileset_id: string; + file_name: string; + orig_name: string; + size: number; + share_url: string; + file_id: string; + download_url: string; +} + +export interface FilesetInfo extends JsonObject { + fileset_id: string; + file_list: FlashFileInfo[]; +} + +export interface FlashFileUrl extends JsonObject { + url: string; +} + +export interface DownloadFilesetResult extends FlashFileUrl { + file_name: string; + file_size: number; +} + +export interface FilesetIdResult extends JsonObject { + fileset_id: string; +} + +export interface SendFlashMsgResult extends JsonObject { + /** Flash sharing has no ordinary message ID; the server returns this sentinel. */ + message_id: 0; +} + +export interface CleanStreamTempFileResult extends JsonObject { + message: string; + removed: number; + freed_bytes: number; + skipped_active: number; +} + +/** Stream errors are carried by failed envelopes; raw() throws for these. */ +export interface StreamErrorData extends JsonObject { + type: 'error'; + data_type: 'error'; +} + +export interface UploadFileStreamStatus extends JsonObject { + type: 'stream'; + stream_id: string; + status: 'file_created' | 'chunk_received'; + received_chunks: number; + total_chunks: number; +} + +export interface UploadFileStreamComplete extends JsonObject { + type: 'response'; + stream_id: string; + status: 'file_complete'; + received_chunks: number; + total_chunks: number; + file_path: string; + file_name: string; + file_size: number; + sha256: string; +} + +export type UploadFileStreamResult = + UploadFileStreamStatus | UploadFileStreamComplete | StreamErrorData; + +export interface DownloadFileStreamInfo extends JsonObject { + type: 'stream'; + data_type: 'file_info'; + file_name: string; + file_size: number; + chunk_size: number; +} + +export interface DownloadFileStreamChunk extends JsonObject { + type: 'stream'; + data_type: 'file_chunk'; + index: number; + /** Base64-encoded bytes. */ + data: string; + size: number; + progress: number; + base64_size: number; +} + +export interface DownloadFileStreamComplete extends JsonObject { + type: 'response'; + data_type: 'file_complete'; + file_name: string; + total_chunks: number; + total_bytes: number; + message: string; +} + +/** One frame's data, not an aggregated download. A streaming transport is required. */ +export type DownloadFileStreamResult = + DownloadFileStreamInfo | DownloadFileStreamChunk | DownloadFileStreamComplete | StreamErrorData; + +export type TestDownloadStreamResult = + | { type: 'stream'; data_type: 'data_chunk'; data: string } + | { type: 'response'; data_type: 'data_complete'; data: string } + | StreamErrorData; diff --git a/packages/sdk/tests/fixtures/action-contracts.ts b/packages/sdk/tests/fixtures/action-contracts.ts new file mode 100644 index 00000000..8c70ebf7 --- /dev/null +++ b/packages/sdk/tests/fixtures/action-contracts.ts @@ -0,0 +1,397 @@ +import type { ActionParams, ActionResult } from '@snowluma/sdk'; + +const album = { group_id: '10001', album_id: 'album' }; +const mediaLike = { ...album, batch_id: '9007199254740993', lloc: 'photo' }; +const mediaUpload = { ...album, album_name: 'Album', file: 'file:///fixture' }; +const download = { file: 'https://example.com/file', file_id: 'cached', chunk_size: '65536' }; +const fileset = { fileset_id: 'fileset' }; +const flashFile = { ...fileset, file_name: 'file.txt', file_index: '0' }; +const peer = { group_id: '10001', user_id: '10002', phone_number: '' }; +const qzoneLike = { tid: 'post', target_uin: '10002', abstime: '1700000000' }; + +// The same examples are compiled against the published declarations and parsed +// by the real server action definitions. They never invoke QQ or perform I/O. +export const actionParams = { + _get_friend_dress: { user_id: '10002' }, + add_custom_face: { file: 'file:///fixture.png' }, + cancel_group_album_media_like: mediaLike, + clean_stream_temp_file: {}, + comment_qzone: { tid: 'post', content: 'Comment', target_uin: '10002', images: [] }, + create_flash_task: { + files: ['file:///one', { file: 'file:///two', name: 'two.txt' }], + name: 'Files', + thumb_path: '', + }, + del_group_album_media: { ...album, lloc: 'photo' }, + delete_custom_face: { emoji_id: 'face' }, + delete_flash_file: fileset, + delete_qzone_msg: { tid: 'post' }, + do_group_album_comment: { ...album, lloc: 'photo', content: 'Comment' }, + download_file_image_stream: download, + download_file_record_stream: download, + download_file_stream: download, + download_fileset: flashFile, + fetch_custom_face_detail: { count: '48' }, + fetch_ptt_text: { message_id: -42 }, + get_ptt_text: { message_id: '-42' }, + get_record_text: { message_id: -42 }, + get_doubt_friends_add_request: { count: '50' }, + get_fileset_id: { share_code: 'share' }, + get_fileset_info: fileset, + get_flash_file_list: fileset, + get_flash_file_url: flashFile, + get_group_album_list: { group_id: '10001' }, + get_group_album_media_list: { ...album, attach_info: '' }, + get_group_signed_list: { group_id: '10001' }, + get_msg_emoji_likes: { message_id: '-42' }, + get_qun_album_list: { group_id: '10001', attach_info: '' }, + get_qzone_feeds: { page_num: '1', count: '10' }, + get_qzone_msg_list: { target_uin: '10002', pos: '0', num: '20' }, + get_rkey_server: {}, + get_share_link: fileset, + like_qzone: qzoneLike, + list_filesets: {}, + modify_custom_face: { emoji_id: 'face', desc: 'Description' }, + move_custom_face_to_front: { emoji_id: 'face' }, + rename_flash_file: { ...fileset, new_name: 'new.txt' }, + request_decrypt_key: { db_path: '/fixture.db' }, + send_ark_share: peer, + send_flash_msg: { ...fileset, group_id: '10001', user_id: '10002' }, + send_group_ark_share: { group_id: '10001' }, + send_qzone_msg: { + content: 'Post', + images: [], + ugc_right: '16', + target_uins: ['10002'], + }, + send_tuwen_ark: { + group_id: '10001', + user_id: '10002', + title: 'Title', + desc: 'Description', + summary: 'Summary', + preview_url: '', + jump_url: 'https://example.com/', + }, + set_doubt_friends_add_request: { flag: 'u_fixture', approve: 'yes' }, + set_group_album_media_like: mediaLike, + set_group_member_invite_policy: { group_id: '10001', policy: 'require_approval' }, + set_group_member_permissions: { + group_id: '10001', + allow_member_upload_album: false, + allow_member_temporary_session: 'off', + allow_member_create_group: 1, + }, + set_group_new_member_history_visibility: { group_id: '10001', visible: 'on' }, + set_group_robot_add_option: { + group_id: '10001', + robot_member_switch: '1', + robot_member_examine: '0', + }, + set_qzone_ban: { user_id: '10002', enable: false }, + set_qzone_msg_right: { tid: 'post', ugc_right: '128', target_uins: ['10002'] }, + share_group_ex: { group_id: '10001' }, + share_peer: peer, + test_download_stream: { error: false }, + unlike_qzone: qzoneLike, + upload_file_stream: { + stream_id: 'fixture', + chunk_data: 'YQ==', + chunk_index: '0', + total_chunks: '1', + file_size: '1', + expected_sha256: '', + is_complete: 'false', + filename: 'file.txt', + reset: false, + verify_only: 0, + file_retention: '0', + }, + upload_image_to_qun_album: mediaUpload, + upload_video_to_qun_album: mediaUpload, + '.handle_quick_operation_async': { context: {}, operation: {} }, +} satisfies { [K in CoveredAction]: ActionParams }; + +// Keep this independent of SnowLumaActionMap: a missing map entry must fail the +// consumer's coverage check rather than silently remove that example. +export type CoveredAction = + | '_get_friend_dress' + | 'add_custom_face' + | 'cancel_group_album_media_like' + | 'clean_stream_temp_file' + | 'comment_qzone' + | 'create_flash_task' + | 'del_group_album_media' + | 'delete_custom_face' + | 'delete_flash_file' + | 'delete_qzone_msg' + | 'do_group_album_comment' + | 'download_file_image_stream' + | 'download_file_record_stream' + | 'download_file_stream' + | 'download_fileset' + | 'fetch_custom_face_detail' + | 'fetch_ptt_text' + | 'get_ptt_text' + | 'get_record_text' + | 'get_doubt_friends_add_request' + | 'get_fileset_id' + | 'get_fileset_info' + | 'get_flash_file_list' + | 'get_flash_file_url' + | 'get_group_album_list' + | 'get_group_album_media_list' + | 'get_group_signed_list' + | 'get_msg_emoji_likes' + | 'get_qun_album_list' + | 'get_qzone_feeds' + | 'get_qzone_msg_list' + | 'get_rkey_server' + | 'get_share_link' + | 'like_qzone' + | 'list_filesets' + | 'modify_custom_face' + | 'move_custom_face_to_front' + | 'rename_flash_file' + | 'request_decrypt_key' + | 'send_ark_share' + | 'send_flash_msg' + | 'send_group_ark_share' + | 'send_qzone_msg' + | 'send_tuwen_ark' + | 'set_doubt_friends_add_request' + | 'set_group_album_media_like' + | 'set_group_member_invite_policy' + | 'set_group_member_permissions' + | 'set_group_new_member_history_visibility' + | 'set_group_robot_add_option' + | 'set_qzone_ban' + | 'set_qzone_msg_right' + | 'share_group_ex' + | 'share_peer' + | 'test_download_stream' + | 'unlike_qzone' + | 'upload_file_stream' + | 'upload_image_to_qun_album' + | 'upload_video_to_qun_album' + | '.handle_quick_operation_async'; + +const flashInfo = { + fileset_id: 'fileset', + file_name: 'file.txt', + orig_name: 'original.txt', + size: 42, + share_url: 'https://example.com/share', + file_id: 'file', + download_url: '', +}; + +export const actionResults = { + _get_friend_dress: { + target_uin: '10002', + is_svip: true, + avatar_url: '', + items: [ + { + app_id: 15, + kind: 'card', + item_id: 1, + name: 'Card', + preview_url: '', + video_url: '', + price: 0, + }, + ], + }, + add_custom_face: { emoji_id: 'face' }, + cancel_group_album_media_like: {}, + clean_stream_temp_file: { message: 'success', removed: 1, freed_bytes: 42, skipped_active: 1 }, + comment_qzone: { comment_id: 'comment' }, + create_flash_task: { fileset_id: 'fileset', task_id: 'fileset' }, + del_group_album_media: { success: true }, + delete_custom_face: null, + delete_flash_file: null, + delete_qzone_msg: null, + do_group_album_comment: { + id: 'comment', + user: { uin: '10002' }, + content: [{ type: 0, content: 'Comment' }], + time: '1700000000', + clientKey: 'fixture', + }, + download_file_image_stream: { + type: 'stream', + data_type: 'file_info', + file_name: 'image', + file_size: 1, + chunk_size: 1, + }, + download_file_record_stream: { + type: 'stream', + data_type: 'file_chunk', + index: 0, + data: 'YQ==', + size: 1, + progress: 100, + base64_size: 4, + }, + download_file_stream: { + type: 'response', + data_type: 'file_complete', + file_name: 'file', + total_chunks: 1, + total_bytes: 1, + message: 'Download completed', + }, + download_fileset: { url: '', file_name: 'file.txt', file_size: 42 }, + fetch_custom_face_detail: [{ emoji_id: 'face', resId: 'face', url: '', md5: '', desc: '' }], + fetch_ptt_text: { text: 'Speech' }, + get_ptt_text: { text: 'Speech' }, + get_record_text: { text: 'Speech' }, + get_doubt_friends_add_request: [ + { + uid: 'u_fixture', + user_id: 10002, + nick: 'User', + source: '', + reason: '', + msg: '', + group_code: '', + reqTime: 0, + }, + ], + get_fileset_id: { fileset_id: 'fileset' }, + get_fileset_info: { fileset_id: 'fileset', file_list: [flashInfo] }, + get_flash_file_list: [flashInfo], + get_flash_file_url: { url: '' }, + get_group_album_list: [ + { + id: 'album', + name: 'Album', + picNum: 1, + createTime: 1, + desc: '', + owner: '10002', + createuin: '10002', + createnickname: 'User', + last_upload_time: 1, + cover: null, + }, + ], + get_group_album_media_list: { + mediaList: [ + { + type: 2, + video: { id: 'video', videoTime: '1000' }, + batchId: '9007199254740993', + uploadTime: '1', + }, + ], + nextAttachInfo: '', + }, + get_group_signed_list: [{ user_id: 10002, nick: 'User', time: 1, rank: 1 }], + get_msg_emoji_likes: [ + { + emoji_id: '1', + emoji_type: 1, + count: 1, + last_reaction_time: 1, + users: [{ user_id: 10002 }], + }, + ], + get_qun_album_list: { + album_list: [ + { + album_id: 'album', + owner: '10002', + name: 'Album', + desc: '', + create_time: '1', + modify_time: '1', + last_upload_time: '1', + upload_number: '9007199254740993', + cover: { + type: 1, + image: { + name: '', + sloc: '', + lloc: '', + photoUrls: [], + defaultUrl: null, + isGif: false, + hasRaw: false, + }, + }, + top_flag: '0', + busi_type: 0, + status: 0, + allow_share: true, + is_subscribe: false, + bitmap: '', + is_share_album: false, + qz_album_type: 0, + cover_type: 0, + default_desc: '', + sort_type: 0, + }, + ], + attach_info: '', + has_more: false, + }, + get_qzone_feeds: { + feeds: [{ uin: 10002, nickname: 'User', time: 1, appid: 311, key: 'post', html: '' }], + has_more: false, + }, + get_qzone_msg_list: { + total: 1, + msglist: [ + { + tid: 'post', + content: 'Post', + time: 1, + comment_num: 0, + is_private: false, + images: [], + }, + ], + }, + get_rkey_server: { expired_time: 1, name: 'SnowLuma', private_rkey: 'fixture' }, + get_share_link: 'https://example.com/share', + like_qzone: null, + list_filesets: [flashInfo], + modify_custom_face: null, + move_custom_face_to_front: null, + rename_flash_file: null, + request_decrypt_key: { db_key: 'fixture' }, + send_ark_share: { arkMsg: '{}' }, + send_flash_msg: { message_id: 0 }, + send_group_ark_share: '{}', + send_qzone_msg: { tid: 'post', time: 1 }, + send_tuwen_ark: null, + set_doubt_friends_add_request: null, + set_group_album_media_like: { id: 'like', status: 1 }, + set_group_member_invite_policy: null, + set_group_member_permissions: null, + set_group_new_member_history_visibility: null, + set_group_robot_add_option: null, + set_qzone_ban: null, + set_qzone_msg_right: { ugc_right: 128 }, + share_group_ex: '{}', + share_peer: { arkMsg: '{}' }, + test_download_stream: { + type: 'response', + data_type: 'data_complete', + data: 'Stream transmission complete', + }, + unlike_qzone: null, + upload_file_stream: { + type: 'stream', + stream_id: 'fixture', + status: 'chunk_received', + received_chunks: 1, + total_chunks: 1, + }, + upload_image_to_qun_album: null, + upload_video_to_qun_album: { id: 'video' }, + '.handle_quick_operation_async': null, +} satisfies { [K in CoveredAction]: ActionResult }; diff --git a/packages/sdk/tests/fixtures/action-types.mts b/packages/sdk/tests/fixtures/action-types.mts new file mode 100644 index 00000000..f7cd9190 --- /dev/null +++ b/packages/sdk/tests/fixtures/action-types.mts @@ -0,0 +1,136 @@ +import type { + ActionParams, + ActionResult, + JsonObject, + JsonValue, + SnowLumaActionMap, + SnowLumaHttpClient, + SnowLumaKnownAction, + SnowLumaWebSocketClient, +} from '@snowluma/sdk'; +import { actionParams, actionResults, type CoveredAction } from './action-contracts.js'; + +type AssertNever = T; +export type MissingActionTypes = AssertNever>; +export type UnregisteredActionTypes = AssertNever< + Exclude +>; +export type UnspecifiedResults = AssertNever< + { + [K in CoveredAction]: JsonValue extends ActionResult ? K : never; + }[CoveredAction] +>; + +type AssertTrue = T; +type Equal = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +export type PttAliasParams = AssertTrue< + Equal, ActionParams<'fetch_ptt_text'>> +>; +export type PttAliasResult = AssertTrue< + Equal, ActionResult<'fetch_ptt_text'>> +>; +export type QuickAlias = AssertTrue< + Equal< + SnowLumaActionMap['.handle_quick_operation_async'], + SnowLumaActionMap['.handle_quick_operation'] + > +>; + +declare const http: SnowLumaHttpClient; +declare const ws: SnowLumaWebSocketClient; + +const feeds = await http.raw('get_qzone_feeds', { page_num: '2', count: 10 }); +const more: boolean = feeds.has_more; +const author: number = feeds.feeds[0].uin; +const speech = await ws.raw('get_record_text', { message_id: -42 }); +const transcription: string = speech.text; +const flash = await http.raw('get_fileset_info', { fileset_id: 'files' }); +const bytes: number = flash.file_list[0].size; +const envelope = await ws.rawResponse('get_fileset_info', actionParams.get_fileset_info); +const filesetId: string = envelope.data.fileset_id; +const album = await http.raw('get_qun_album_list', actionParams.get_qun_album_list); +const count: string = album.album_list[0].upload_number; +const cover: string | null | undefined = album.album_list[0].cover?.image?.defaultUrl?.url; +const upload = await ws.raw('upload_file_stream', { stream_id: 'fixture', verify_only: true }); + +if (upload.type === 'response') { + const checksum: string = upload.sha256; + const received: number = upload.received_chunks; + void [checksum, received]; +} else if (upload.type === 'stream') { + const status: 'file_created' | 'chunk_received' = upload.status; + void status; +} + +declare const frame: ActionResult<'download_file_stream'>; + +if (frame.data_type === 'file_chunk') { + const chunk: string = frame.data; + const index: number = frame.index; + void [chunk, index]; +} + +// Extra JSON fields and all accepted flash-task forms remain available. +export const extra: ActionParams<'send_qzone_msg'> = { content: 'Post', vendor_option: true }; +export const singlePath: ActionParams<'create_flash_task'> = { files: '/fixture' }; +export const namedFile: ActionParams<'create_flash_task'> = { + files: { file: '/fixture', name: 'file' }, +}; +export const legacyScalars: ActionParams<'modify_custom_face'> = { emoji_id: 123, desc: false }; +export const onePermission: ActionParams<'set_group_member_permissions'> = { + group_id: 10001, + allow_member_upload_album: false, +}; +export const completed: ActionResult<'upload_file_stream'> = { + type: 'response', + stream_id: 'fixture', + status: 'file_complete', + received_chunks: 1, + total_chunks: 1, + file_path: '/fixture', + file_name: 'file', + file_size: 1, + sha256: 'fixture', +}; +export const streamError: ActionResult<'download_file_stream'> = { + type: 'error', + data_type: 'error', +}; + +// @ts-expect-error publishing requires content +export const missingContent: ActionParams<'send_qzone_msg'> = {}; +// @ts-expect-error flash files must be paths or named file objects +export const invalidFlashFile: ActionParams<'create_flash_task'> = { files: [123] }; +// @ts-expect-error a permission patch needs at least one flag +export const missingPermission: ActionParams<'set_group_member_permissions'> = { group_id: 10001 }; +export const invalidPolicy: ActionParams<'set_group_member_invite_policy'> = { + group_id: 10001, + // @ts-expect-error the policy is a closed server enum + policy: 'admin_only', +}; +// @ts-expect-error uint64 album counters are returned as strings +export const invalidCount: ActionResult<'get_qun_album_list'>['album_list'][number]['upload_number'] = 1; +// @ts-expect-error voice transcription is an object with a text field +export const invalidSpeech: ActionResult<'fetch_ptt_text'> = 'Speech'; + +// The open action namespace and JSON escape hatch remain available. +declare const dynamicAction: string; +declare const dynamicParams: JsonObject; +const custom: Promise = http.raw('consumer_extension', { custom: true }); +const dynamic: Promise = ws.raw(dynamicAction, dynamicParams); + +export { + more, + author, + transcription, + bytes, + filesetId, + count, + cover, + custom, + dynamic, + actionResults, +}; +export type NewParams = ActionParams<'send_qzone_msg'>; +export type NewResult = ActionResult<'send_qzone_msg'>; diff --git a/packages/sdk/tests/package.test.ts b/packages/sdk/tests/package.test.ts index fa0c1876..b79107a4 100644 --- a/packages/sdk/tests/package.test.ts +++ b/packages/sdk/tests/package.test.ts @@ -41,6 +41,14 @@ beforeAll(() => { const installed = path.join(consumerRoot, 'node_modules', '@snowluma', 'sdk'); mkdirSync(installed, { recursive: true }); copyFileSync(path.join(sdkRoot, 'package.json'), path.join(installed, 'package.json')); + copyFileSync( + path.join(sdkRoot, 'tests', 'fixtures', 'action-types.mts'), + path.join(consumerRoot, 'action-types.mts'), + ); + copyFileSync( + path.join(sdkRoot, 'tests', 'fixtures', 'action-contracts.ts'), + path.join(consumerRoot, 'action-contracts.ts'), + ); // Compile fresh output outside the workspace so neither stale dist files nor // Vitest's source resolver can hide a broken published entry point. @@ -139,6 +147,7 @@ assert.equal(actions.isSnowLumaAction('consumer_extension'), false); '--moduleResolution', resolution, path.join(consumerRoot, 'consumer.mts'), + path.join(consumerRoot, 'action-types.mts'), ]); }, 30_000,