fix(onebot): get_group_notice 返回「发给新成员」公告

get_t_list 会把「发给新成员」类公告单独放在 inst 数组里,之前只遍历 feeds,
这类公告(群规通常发在这里)完全拿不到。现在把 inst 条目追加在 feeds 之后返回,
已有条目的顺序不变,并按 fid 去重。

新增字段:
- send_to_new_member:是否为「发给新成员」公告
- pinned:由 inst 条目的 settings.inst_no_pinned 映射(0 = 置顶),
  feeds 条目读不到置顶状态,不带该字段

Closes #2082
This commit is contained in:
sj817
2026-10-05 11:07:42 +08:00
parent 8d69a8e086
commit 13a8a2b8dd
3 changed files with 110 additions and 21 deletions
+2
View File
@@ -78,6 +78,7 @@ export interface WebApiGroupNoticeFeed {
remind_ts: number
tip_window_type: number
confirm_required: number
inst_no_pinned?: number // 仅 inst 条目有:0 = 置顶中,1 = 未置顶
};
read_num: number;
is_read: number;
@@ -92,6 +93,7 @@ export interface WebApiGroupNoticeRet {
read_only: number
role: number
feeds: WebApiGroupNoticeFeed[]
inst?: WebApiGroupNoticeFeed[] // 「发给新成员」类公告,不在 feeds 里
group: {
group_id: number
class_ext: number
@@ -21,6 +21,8 @@ const ReturnSchema = Type.Array(Type.Object({
}, { description: '公告内容' }),
settings: Type.Optional(Type.Any({ description: '设置项' })),
read_num: Type.Optional(Type.Number({ description: '阅读数' })),
send_to_new_member: Type.Optional(Type.Boolean({ description: '是否为「发给新成员」公告' })),
pinned: Type.Optional(Type.Boolean({ description: '是否置顶(仅「发给新成员」公告可读到)' })),
}), { description: '群公告列表' });
type ReturnType = Static<typeof ReturnSchema>;
@@ -44,30 +46,44 @@ export class GetGroupNotice extends OneBotAction<PayloadType, ReturnType> {
throw new Error('获取公告失败');
}
const retNotices: ReturnType = [];
for (const key in ret.feeds) {
if (!ret.feeds[key]) {
continue;
const seen = new Set<string>();
// 「发给新成员」类公告只出现在 inst 里,追加在 feeds 之后,保持原有条目的顺序不变
for (const [list, sendToNewMember] of [[ret.feeds, false], [ret.inst, true]] as const) {
for (const retApiNotice of Array.isArray(list) ? list : []) {
if (!retApiNotice || seen.has(retApiNotice.fid)) {
continue;
}
seen.add(retApiNotice.fid);
retNotices.push(this.toNotice(retApiNotice, sendToNewMember));
}
const retApiNotice: WebApiGroupNoticeFeed = ret.feeds[key];
const image = retApiNotice.msg.pics?.map((pic) => {
return { id: pic.id, height: pic.h, width: pic.w };
}) || [];
const retNotice: ReturnType[number] = {
notice_id: retApiNotice.fid,
sender_id: retApiNotice.u,
publish_time: retApiNotice.pubt,
message: {
text: retApiNotice.msg.text,
image,
images: image,
},
settings: retApiNotice.settings,
read_num: retApiNotice.read_num,
};
retNotices.push(retNotice);
}
return retNotices;
}
private toNotice (retApiNotice: WebApiGroupNoticeFeed, sendToNewMember: boolean): ReturnType[number] {
const image = retApiNotice.msg.pics?.map((pic) => {
return { id: pic.id, height: pic.h, width: pic.w };
}) || [];
const retNotice: ReturnType[number] = {
notice_id: retApiNotice.fid,
sender_id: retApiNotice.u,
publish_time: retApiNotice.pubt,
message: {
text: retApiNotice.msg.text,
image,
images: image,
},
settings: retApiNotice.settings,
read_num: retApiNotice.read_num,
send_to_new_member: sendToNewMember,
};
// 置顶状态只在 inst 条目的 settings.inst_no_pinned 里:0 = 置顶中,1 = 未置顶
const instNoPinned = retApiNotice.settings?.inst_no_pinned;
if (typeof instNoPinned === 'number') {
retNotice.pinned = instNoPinned === 0;
}
return retNotice;
}
}
@@ -0,0 +1,71 @@
// StreamBasic must be evaluated before OneBotAction: the two modules import each other,
// and loading OneBotAction first leaves BasicStream extending an uninitialized binding.
import '../napcat-onebot/action/stream/StreamBasic';
import { describe, expect, test, vi } from 'vitest';
import { GetGroupNotice } from '../napcat-onebot/action/group/GetGroupNotice';
function feed (fid: string, settings?: Record<string, number>) {
return {
u: 10001,
fid,
pubt: 1750000000,
msg: { text: `notice ${fid}`, text_face: '', title: '' },
type: 1,
fn: 0,
cn: 0,
vn: 0,
settings,
read_num: 3,
is_read: 0,
is_all_confirm: 0,
};
}
function createAction (ret: unknown) {
return new GetGroupNotice(
undefined as never,
{ apis: { WebApi: { getGroupNotice: vi.fn().mockResolvedValue(ret) } } } as never
);
}
describe('_get_group_notice', () => {
test('returns "send to new member" notices from inst after feeds', async () => {
const action = createAction({
ec: 0,
feeds: [feed('feed-1'), feed('feed-2')],
inst: [feed('inst-1', { inst_no_pinned: 0 })],
});
const notices = await action._handle({ group_id: '123' });
expect(notices.map(n => n.notice_id)).toEqual(['feed-1', 'feed-2', 'inst-1']);
expect(notices.map(n => n.send_to_new_member)).toEqual([false, false, true]);
expect(notices[2]?.message.text).toBe('notice inst-1');
});
test('maps inst_no_pinned to pinned only when the field is present', async () => {
const action = createAction({
ec: 0,
feeds: [feed('feed-1', { tip_window_type: 1 })],
inst: [feed('pinned', { inst_no_pinned: 0 }), feed('unpinned', { inst_no_pinned: 1 })],
});
const notices = await action._handle({ group_id: '123' });
expect(notices.find(n => n.notice_id === 'feed-1')?.pinned).toBeUndefined();
expect(notices.find(n => n.notice_id === 'pinned')?.pinned).toBe(true);
expect(notices.find(n => n.notice_id === 'unpinned')?.pinned).toBe(false);
});
test('keeps working when inst is missing and skips duplicated notices', async () => {
const withoutInst = await createAction({ ec: 0, feeds: [feed('feed-1')] })._handle({ group_id: '123' });
expect(withoutInst.map(n => n.notice_id)).toEqual(['feed-1']);
const duplicated = await createAction({
ec: 0,
feeds: [feed('same')],
inst: [feed('same', { inst_no_pinned: 0 })],
})._handle({ group_id: '123' });
expect(duplicated.map(n => n.notice_id)).toEqual(['same']);
});
});