feat(onebot): add group share links

Retrieve group invitation links with compatible request defaults and explicit error reporting. Add a typed SDK helper and discoverable action documentation.

Fixes #521
This commit is contained in:
DrSmoothl
2026-10-13 10:34:41 +08:00
parent c7428363cb
commit f85b28d23c
14 changed files with 587 additions and 2 deletions
+15
View File
@@ -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<string> {
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<void> {
return SendTuwenArk.invoke(this.ctx, params);
+99 -1
View File
@@ -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": "群管理",
+99 -1
View File
@@ -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": "群管理",
+26
View File
@@ -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: '获取群列表',
@@ -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'],
});
});
});
@@ -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>;
}
@@ -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<OidbGroupShareLinkRequest>): Uint8Array =>
protobuf_encode<OidbBase<OidbGroupShareLinkRequest>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbGroupShareLinkResponse> =>
protobuf_decode<OidbBase<OidbGroupShareLinkResponse>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<string> =>
invokeOidb(deps, GetGroupShareLink, params);
}
@@ -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<OidbSender['sendRawPacket']>().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();
});
});
+1
View File
@@ -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.
+2
View File
@@ -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'),
+20
View File
@@ -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,
+2
View File
@@ -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 };
+6
View File
@@ -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;
}
+35
View File
@@ -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 () =>