diff --git a/packages/core/src/bridge/apis/contacts.ts b/packages/core/src/bridge/apis/contacts.ts index 047712fe..7b378a4b 100644 --- a/packages/core/src/bridge/apis/contacts.ts +++ b/packages/core/src/bridge/apis/contacts.ts @@ -25,6 +25,7 @@ import { } from '@snowluma/protocol/services/qidian/fetch-corp-info'; import { GetBuddyRecommendArk } from '@snowluma/protocol/oidb-services/contacts/get-buddy-recommend-ark'; import { GetGroupRecommendArk } from '@snowluma/protocol/oidb-services/contacts/get-group-recommend-ark'; +import { GetGroupShareLink } from '@snowluma/protocol/oidb-services/contacts/get-group-share-link'; import { SendTuwenArk, type SendTuwenArkParams, @@ -157,6 +158,20 @@ export class ContactsApi { return GetGroupRecommendArk.invoke(this.ctx, { groupId }); } + getGroupShareLink( + groupId: number, + needShortUrl = true, + srcId = 73, + additionalParam = '', + ): Promise { + return GetGroupShareLink.invoke(this.ctx, { + groupId, + needShortUrl, + srcId, + additionalParam, + }); + } + /** Send a custom 图文 ark card to a C2C peer or group (0xdc2_34). */ sendTuwenArk(params: SendTuwenArkParams): Promise { return SendTuwenArk.invoke(this.ctx, params); diff --git a/packages/mcp/src/generated/catalog.json b/packages/mcp/src/generated/catalog.json index 6051be46..1ed819b0 100644 --- a/packages/mcp/src/generated/catalog.json +++ b/packages/mcp/src/generated/catalog.json @@ -5981,6 +5981,104 @@ }, "category": "群文件" }, + { + "name": "get_group_share_link", + "aliases": [], + "summary": "获取群分享链接", + "returns": "用于分享或邀请加群的链接,可作为群二维码内容。", + "returnsSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "加群分享链接" + } + }, + "required": [ + "url" + ] + }, + "readOnly": true, + "params": [ + { + "name": "group_id", + "type": "uint", + "required": true, + "role": "group_id", + "schema": { + "type": "integer", + "minimum": 1 + }, + "desc": "群号" + }, + { + "name": "need_short_url", + "type": "bool", + "required": false, + "schema": { + "type": "boolean" + }, + "default": true, + "desc": "是否获取短链接" + }, + { + "name": "src_id", + "type": "int", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "default": 73, + "desc": "来源标识(兼容参数)" + }, + { + "name": "additional_param", + "type": "string", + "required": false, + "schema": { + "type": "string" + }, + "default": "", + "desc": "附加参数" + } + ], + "invariants": [], + "inputSchema": { + "type": "object", + "properties": { + "group_id": { + "type": "integer", + "minimum": 1, + "description": "群号", + "x-role": "group_id" + }, + "need_short_url": { + "type": "boolean", + "description": "是否获取短链接", + "default": true + }, + "src_id": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "来源标识(兼容参数)", + "default": 73 + }, + "additional_param": { + "type": "string", + "description": "附加参数", + "default": "" + } + }, + "required": [ + "group_id" + ], + "additionalProperties": true + }, + "category": "群信息" + }, { "name": "get_group_shut_list", "aliases": [], @@ -13326,7 +13424,7 @@ }, { "category": "群信息", - "count": 6 + "count": 7 }, { "category": "群管理", diff --git a/packages/mcp/src/generated/catalog.ts b/packages/mcp/src/generated/catalog.ts index 0f005258..91c229ca 100644 --- a/packages/mcp/src/generated/catalog.ts +++ b/packages/mcp/src/generated/catalog.ts @@ -5984,6 +5984,104 @@ export const ACTIONS: CatalogAction[] = [ }, "category": "群文件" }, + { + "name": "get_group_share_link", + "aliases": [], + "summary": "获取群分享链接", + "returns": "用于分享或邀请加群的链接,可作为群二维码内容。", + "returnsSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "加群分享链接" + } + }, + "required": [ + "url" + ] + }, + "readOnly": true, + "params": [ + { + "name": "group_id", + "type": "uint", + "required": true, + "role": "group_id", + "schema": { + "type": "integer", + "minimum": 1 + }, + "desc": "群号" + }, + { + "name": "need_short_url", + "type": "bool", + "required": false, + "schema": { + "type": "boolean" + }, + "default": true, + "desc": "是否获取短链接" + }, + { + "name": "src_id", + "type": "int", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "default": 73, + "desc": "来源标识(兼容参数)" + }, + { + "name": "additional_param", + "type": "string", + "required": false, + "schema": { + "type": "string" + }, + "default": "", + "desc": "附加参数" + } + ], + "invariants": [], + "inputSchema": { + "type": "object", + "properties": { + "group_id": { + "type": "integer", + "minimum": 1, + "description": "群号", + "x-role": "group_id" + }, + "need_short_url": { + "type": "boolean", + "description": "是否获取短链接", + "default": true + }, + "src_id": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295, + "description": "来源标识(兼容参数)", + "default": 73 + }, + "additional_param": { + "type": "string", + "description": "附加参数", + "default": "" + } + }, + "required": [ + "group_id" + ], + "additionalProperties": true + }, + "category": "群信息" + }, { "name": "get_group_shut_list", "aliases": [], @@ -13330,7 +13428,7 @@ export const CATEGORIES: CatalogCategory[] = [ }, { "category": "群信息", - "count": 6 + "count": 7 }, { "category": "群管理", diff --git a/packages/onebot/src/actions/group-info.ts b/packages/onebot/src/actions/group-info.ts index a1d64ed3..c63cd64c 100644 --- a/packages/onebot/src/actions/group-info.ts +++ b/packages/onebot/src/actions/group-info.ts @@ -31,6 +31,32 @@ export const groupInfoReturnsSchema = { }; export const actions = [ + groupAction({ + name: 'get_group_share_link', + summary: '获取群分享链接', + readOnly: true, + params: { + need_short_url: f.bool().default(true).describe('是否获取短链接'), + src_id: f.int({ min: 0, max: 0xffffffff }).default(73).describe('来源标识(兼容参数)'), + additional_param: f.string().default('').describe('附加参数'), + }, + returns: '用于分享或邀请加群的链接,可作为群二维码内容。', + returnsSchema: { + type: 'object', + properties: { url: { type: 'string', description: '加群分享链接' } }, + required: ['url'], + }, + run: async (p, ctx) => + okResponse({ + url: await ctx.bridge.apis.contacts.getGroupShareLink( + p.group_id, + p.need_short_url, + p.src_id, + p.additional_param, + ), + }), + }), + defineAction({ name: 'get_group_list', summary: '获取群列表', diff --git a/packages/onebot/tests/group-share-link.test.ts b/packages/onebot/tests/group-share-link.test.ts new file mode 100644 index 00000000..c75b8a26 --- /dev/null +++ b/packages/onebot/tests/group-share-link.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ApiHandler, type ApiActionContext } from '../src/api-handler'; +import { ACTION_REGISTRY } from '../src/actions'; +import { RETCODE, type JsonObject } from '../src/types'; + +function setup() { + const getGroupShareLink = vi.fn().mockResolvedValue('https://qm.qq.com/q/example'); + const api = new ApiHandler({ + bridge: { apis: { contacts: { getGroupShareLink } } }, + } as unknown as ApiActionContext); + return { api, getGroupShareLink }; +} + +describe('get_group_share_link', () => { + it('returns the requested result shape with the documented defaults', async () => { + const { api, getGroupShareLink } = setup(); + const response = await api.handle('get_group_share_link', { group_id: 12345 }); + + expect(response).toMatchObject({ + status: 'ok', + retcode: 0, + data: { url: 'https://qm.qq.com/q/example' }, + }); + expect(getGroupShareLink).toHaveBeenCalledExactlyOnceWith(12345, true, 73, ''); + }); + + it('accepts numeric strings and preserves explicit false, zero, and additional parameters', async () => { + const { api, getGroupShareLink } = setup(); + const response = await api.handle('get_group_share_link', { + group_id: '12345', + need_short_url: 'false', + src_id: '0', + additional_param: 'source=二维码&test=1', + }); + + expect(response.status).toBe('ok'); + expect(getGroupShareLink).toHaveBeenCalledExactlyOnceWith( + 12345, + false, + 0, + 'source=二维码&test=1', + ); + }); + + it.each<{ field: string; params: JsonObject }>([ + { field: 'group_id', params: {} }, + { field: 'group_id', params: { group_id: 0 } }, + { field: 'group_id', params: { group_id: -1 } }, + { field: 'group_id', params: { group_id: 'invalid' } }, + { field: 'need_short_url', params: { group_id: 12345, need_short_url: 'invalid' } }, + { field: 'src_id', params: { group_id: 12345, src_id: -1 } }, + { field: 'src_id', params: { group_id: 12345, src_id: 0x100000000 } }, + { field: 'src_id', params: { group_id: 12345, src_id: 'invalid' } }, + { field: 'additional_param', params: { group_id: 12345, additional_param: {} } }, + ])('rejects invalid $field before requesting a link', async ({ field, params }) => { + const { api, getGroupShareLink } = setup(); + const response = await api.handle('get_group_share_link', params); + + expect(response).toMatchObject({ status: 'failed', retcode: RETCODE.BAD_REQUEST }); + expect(response.wording).toContain(field); + expect(getGroupShareLink).not.toHaveBeenCalled(); + }); + + it('surfaces the failure through the shared action error handler', async () => { + const { api, getGroupShareLink } = setup(); + getGroupShareLink.mockRejectedValue(new Error('group share link unavailable')); + + await expect( + api.handle('get_group_share_link', { group_id: 12345 }), + ).resolves.toMatchObject({ + status: 'failed', + retcode: RETCODE.ACTION_FAILED, + data: null, + wording: 'group share link unavailable', + }); + }); + + it('publishes its complete parameter and result contract', () => { + const action = ACTION_REGISTRY.actions.find( + (entry) => entry.canonical === 'get_group_share_link', + ); + expect(action).toBeDefined(); + const doc = action!.spec.describe(); + + expect(doc.readOnly).toBe(true); + expect(doc.params).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'group_id', required: true }), + expect.objectContaining({ name: 'need_short_url', default: true }), + expect.objectContaining({ name: 'src_id', default: 73 }), + expect.objectContaining({ name: 'additional_param', default: '' }), + ]), + ); + expect(doc.returnsSchema).toMatchObject({ + properties: { url: { type: 'string' } }, + required: ['url'], + }); + }); +}); diff --git a/packages/proto-defs/src/oidb-actions/group-share-link.ts b/packages/proto-defs/src/oidb-actions/group-share-link.ts new file mode 100644 index 00000000..b7b31cfc --- /dev/null +++ b/packages/proto-defs/src/oidb-actions/group-share-link.ts @@ -0,0 +1,20 @@ +import type { bool, pb, pb_optional, uint_64 } from '@snowluma/proton'; + +export interface GroupShareLinkRequest { + groupCode?: pb<1, uint_64>; + additionalParam?: pb_optional<3, string>; + needShortUrl?: pb_optional<4, bool>; +} + +export interface OidbGroupShareLinkRequest { + type?: pb<1, uint_64>; + group?: pb<12, GroupShareLinkRequest>; +} + +export interface GroupShareLinkResponse { + url?: pb<1, string>; +} + +export interface OidbGroupShareLinkResponse { + group?: pb<12, GroupShareLinkResponse>; +} diff --git a/packages/protocol/src/oidb-services/contacts/get-group-share-link.ts b/packages/protocol/src/oidb-services/contacts/get-group-share-link.ts new file mode 100644 index 00000000..39035fa8 --- /dev/null +++ b/packages/protocol/src/oidb-services/contacts/get-group-share-link.ts @@ -0,0 +1,50 @@ +import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; +import type { OidbBase } from '@snowluma/proto-defs/oidb'; +import type { + OidbGroupShareLinkRequest, + OidbGroupShareLinkResponse, +} from '@snowluma/proto-defs/oidb-actions/group-share-link'; +import { invokeOidb, type OidbSender } from '../../oidb-service'; + +export namespace GetGroupShareLink { + export const command = 0x9060; + export const subCommand = 3; + export const uinForm = true; + + export interface Params { + groupId: number; + needShortUrl: boolean; + srcId: number; + additionalParam: string; + } + + export type Deps = OidbSender; + + export const serialize = (_ctx: Deps, p: Params): OidbGroupShareLinkRequest => ({ + type: 2n, + group: { + groupCode: BigInt(p.groupId), + additionalParam: p.additionalParam, + needShortUrl: p.needShortUrl, + }, + }); + + export const deserialize = (_ctx: Deps, body: OidbGroupShareLinkResponse): string => { + const url = body.group?.url; + + if (!url?.trim()) { + throw new Error('group share link response did not include a URL'); + } + + return url; + }; + + export const encode = (env: OidbBase): Uint8Array => + protobuf_encode>(env); + + export const decode = (bytes: Uint8Array): OidbBase => + protobuf_decode>(bytes); + + export const invoke = (deps: Deps, params: Params): Promise => + invokeOidb(deps, GetGroupShareLink, params); +} diff --git a/packages/protocol/tests/oidb-services/contacts/get-group-share-link.test.ts b/packages/protocol/tests/oidb-services/contacts/get-group-share-link.test.ts new file mode 100644 index 00000000..920d2115 --- /dev/null +++ b/packages/protocol/tests/oidb-services/contacts/get-group-share-link.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SendPacketResult } from '@snowluma/common/packet-sender'; +import { GetGroupShareLink } from '../../../src/oidb-services/contacts/get-group-share-link'; +import type { OidbSender } from '../../../src/oidb-service'; +import { env, m, s, v } from '../_pb-oracle'; + +const shortUrl = 'https://qm.qq.com/q/example'; +const params: GetGroupShareLink.Params = { + groupId: 12345, + needShortUrl: true, + srcId: 73, + additionalParam: '', +}; + +function sender(body: number[] = m(12, s(1, shortUrl))) { + const packet: SendPacketResult = { + success: true, + gotResponse: true, + errorCode: 0, + errorMessage: '', + responseData: Buffer.from(env(0x9060, 3, body, true), 'hex'), + }; + return { + packet, + sendRawPacket: vi.fn().mockResolvedValue(packet), + }; +} + +describe('GetGroupShareLink', () => { + it('requests a short group link using the verified packet layout', async () => { + const deps = sender(); + + await expect(GetGroupShareLink.invoke(deps, params)).resolves.toBe(shortUrl); + + const body = [...v(1, 2), ...m(12, [...v(1, 12345), ...s(3, ''), ...v(4, 1)])]; + expect(deps.sendRawPacket).toHaveBeenCalledExactlyOnceWith( + 'OidbSvcTrpcTcp.0x9060_3', + new Uint8Array(Buffer.from(env(0x9060, 3, body, true), 'hex')), + ); + }); + + it('preserves a false short-link flag, additional parameters, and the returned long URL', async () => { + const url = 'https://qm.qq.com/cgi-bin/qm/qr?k=example&jump_from=webapi'; + const additionalParam = 'source=群二维码&test=1'; + const deps = sender(m(12, s(1, url))); + + await expect( + GetGroupShareLink.invoke(deps, { ...params, needShortUrl: false, additionalParam }), + ).resolves.toBe(url); + + const body = [...v(1, 2), ...m(12, [...v(1, 12345), ...s(3, additionalParam), ...v(4, 0)])]; + expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe( + env(0x9060, 3, body, true), + ); + }); + + it.each([0, 73, 0xffffffff])( + 'retains client-compatible behavior for source %s', + async (srcId) => { + const deps = sender(); + await GetGroupShareLink.invoke(deps, { ...params, srcId }); + + const body = [...v(1, 2), ...m(12, [...v(1, 12345), ...s(3, ''), ...v(4, 1)])]; + expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe( + env(0x9060, 3, body, true), + ); + }, + ); + + it.each([ + ['missing result', []], + ['missing URL', m(12, [])], + ['empty URL', m(12, s(1, ''))], + ['blank URL', m(12, s(1, ' '))], + ] as const)('rejects a successful response with %s', async (_name, body) => { + await expect(GetGroupShareLink.invoke(sender([...body]), params)).rejects.toThrow( + 'group share link response did not include a URL', + ); + }); + + it('preserves server errors even if the response includes a URL', async () => { + const deps = sender(); + deps.packet.responseData = Buffer.from([ + ...v(1, 0x9060), + ...v(2, 3), + ...v(3, 403), + ...m(4, m(12, s(1, shortUrl))), + ...s(5, 'permission denied'), + ]); + + await expect(GetGroupShareLink.invoke(deps, params)).rejects.toMatchObject({ + name: 'OidbError', + code: 403, + serverMsg: 'permission denied', + }); + }); + + it('reports a transport timeout instead of inventing a URL', async () => { + const deps = sender(); + deps.packet.success = false; + deps.packet.gotResponse = false; + deps.packet.errorMessage = 'request timed out'; + + await expect(GetGroupShareLink.invoke(deps, params)).rejects.toThrow('request timed out'); + }); + + it('rejects a truncated response', async () => { + const deps = sender(); + deps.packet.responseData = Buffer.from([0x22, 0x03, 0x62, 0xff]); + + await expect(GetGroupShareLink.invoke(deps, params)).rejects.toThrow(); + }); +}); diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index f9e4ecae..cbefcba3 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `getGroupShareLink()` and typed `get_group_share_link` support for group invitation links. - Breaking: require Node.js 24 or newer. - Add `CustomReplySegment` and `message.customReply()` for inline quote previews inside merged-forward nodes. - Expose optional inline content and quote metadata on received `ReplySegment` values. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index f1a8d5d0..6e2f4b18 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -62,6 +62,8 @@ const bot = new SnowLumaHttpClient({ await bot.sendPrivateMessage(10001, text('pong')); +const { url } = await bot.getGroupShareLink(123456); + await bot.sendGroupMessage(123456, [ message.text('Hello'), message.at('all'), diff --git a/packages/sdk/src/client/api-client.ts b/packages/sdk/src/client/api-client.ts index a59b63de..b7e46bf0 100644 --- a/packages/sdk/src/client/api-client.ts +++ b/packages/sdk/src/client/api-client.ts @@ -153,6 +153,26 @@ export abstract class SnowLumaApiClient { ); } + getGroupShareLink( + groupId: number, + options?: RequestOptions & { + needShortUrl?: boolean; + srcId?: number; + additionalParam?: string; + }, + ) { + return this.call( + 'get_group_share_link', + { + group_id: groupId, + need_short_url: options?.needShortUrl, + src_id: options?.srcId, + additional_param: options?.additionalParam, + }, + options, + ); + } + getGroupMemberInfo( groupId: number, userId: number, diff --git a/packages/sdk/src/types/actions.ts b/packages/sdk/src/types/actions.ts index af644670..d74088fd 100644 --- a/packages/sdk/src/types/actions.ts +++ b/packages/sdk/src/types/actions.ts @@ -21,6 +21,7 @@ import type { GetGroupHonorInfoParams, GetGroupSystemMsgParams, GetGroupInfoParams, + GetGroupShareLinkParams, GetGroupListParams, GetGroupMemberInfoParams, GetGroupMemberListParams, @@ -134,6 +135,7 @@ export interface SnowLumaActionMap { delete_friend: { params: DeleteFriendParams; data: EmptyData }; get_group_list: { params: GetGroupListParams; data: JsonObject[] }; get_group_info: { params: GetGroupInfoParams; data: JsonObject }; + get_group_share_link: { params: GetGroupShareLinkParams; data: { url: string } }; get_group_member_list: { params: GetGroupMemberListParams; data: JsonObject[] }; get_group_member_info: { params: GetGroupMemberInfoParams; data: JsonObject }; get_group_honor_info: { params: GetGroupHonorInfoParams; data: JsonValue }; diff --git a/packages/sdk/src/types/params.ts b/packages/sdk/src/types/params.ts index 2d5de2a7..07cf844d 100644 --- a/packages/sdk/src/types/params.ts +++ b/packages/sdk/src/types/params.ts @@ -52,6 +52,12 @@ export interface GetGroupInfoParams extends GroupIdParams { no_cache?: boolean; } +export interface GetGroupShareLinkParams extends GroupIdParams { + need_short_url?: boolean; + src_id?: number; + additional_param?: string; +} + export interface GetGroupMemberListParams extends GroupIdParams { no_cache?: boolean; } diff --git a/packages/sdk/tests/http-client.test.ts b/packages/sdk/tests/http-client.test.ts index bed7f2f7..f6b43219 100644 --- a/packages/sdk/tests/http-client.test.ts +++ b/packages/sdk/tests/http-client.test.ts @@ -7,6 +7,41 @@ import { } from '../src'; describe('SnowLumaHttpClient', () => { + it('requests group share links with default and explicit options', async () => { + const url = 'https://qm.qq.com/q/example'; + const fetchImpl = vi + .fn() + .mockImplementation( + async () => + new Response(JSON.stringify({ status: 'ok', retcode: 0, data: { url } })), + ); + const client = new SnowLumaHttpClient({ fetch: fetchImpl }); + + await expect(client.getGroupShareLink(12345)).resolves.toEqual({ url }); + await expect( + client.getGroupShareLink(12345, { + needShortUrl: false, + srcId: 0, + additionalParam: 'source=二维码', + }), + ).resolves.toEqual({ url }); + + expect( + fetchImpl.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string)), + ).toEqual([ + { action: 'get_group_share_link', params: { group_id: 12345 } }, + { + action: 'get_group_share_link', + params: { + group_id: 12345, + need_short_url: false, + src_id: 0, + additional_param: 'source=二维码', + }, + }, + ]); + }); + it('maps extended group-info refresh options to OneBot field names', async () => { const fetchImpl = vi.fn().mockImplementation( async () =>