fix(events): resolve stranger nickname on group_invite (no more 未知昵称)

User report: when a stranger requests to join a group, the bot
receives `user_id=<groupId>` (instead of the requester's actual
uin) and `get_stranger_info` returns no nickname → bot displays
"未知昵称".

Two layered bugs:

  1. **Decoder fallback was wrong.** `decodeGroupJoinRequest` /
     `decodeGroupInvitation` / `decodeGroupInvite` all passed
     `ctx.fromUin` as the fallback uin when `resolveUidToUin` missed
     the local cache. But for group-scoped pushes `ctx.fromUin` is
     the GROUP's own uin — falling back to it produces the bogus
     `fromUin === groupId` collision the user saw. Fix: pass 0.

  2. **No async resolve for stranger UIDs.** The push only carries
     the requester's UID string. When the UID isn't in our cache
     (true for any stranger who isn't yet a group member), we have
     no UIN — and our UIN-form `FetchUserProfile` can't help. The
     pipeline never tried to look up the stranger by UID, so the
     event went out with `fromUin=0` (or, with the old fallback,
     the group's own uin).

Cross-checked against Lagrange.Core's flow:

  dev/Lagrange.Core/.../MessagingLogic.cs:215-224
    case GroupSysRequestJoinEvent join:
        var fetchUidEvent = FetchUserInfoEvent.Create(join.TargetUid);
        var results = await Collection.Business.SendEvent(fetchUidEvent);
        uint targetUin = results.Count == 0 ? 0
            : ((FetchUserInfoEvent)results[0]).UserInfo.Uin;
        var joinArgs = new GroupJoinRequestEvent(join.GroupUin, targetUin);

  dev/Lagrange.Core/.../OidbSvcTrpcTcp0xFE1_2.cs:9-26
    — UID-form variant of the same OIDB 0xFE1_2: field 1 is `uid:
      string`, envelope `reserved=0` (no uinForm flag).

  dev/Lagrange.Core/.../FetchUserInfoService.cs:66-78
    — picks UID vs UIN form based on which input is set.

Changes:

  - `proto-defs/oidb-actions/base.ts`: add `OidbUserInfoByUidRequest`
    (same shape as the UIN variant but field 1 is `uid: string`).
  - `bridge/oidb-services/contacts/fetch-user-profile-by-uid.ts`:
    new namespace at 0xFE1_2 with UID-form encoding, no `uinForm`.
  - `bridge/msg-push/decoders/group-join-request.ts`: 3 decoders'
    `resolveUidToUin` fallbacks change `ctx.fromUin` → 0.
  - `bridge/packet-pipeline.ts`: new `resolveStrangerProfile(uid)`
    dep + `needsStrangerResolve` check + async dispatch that fires
    the lookup before emitting the event. Fail open if the lookup
    rejects (event still ships with `fromUin=0` so the OneBot bot
    can fall back to the uid in the flag).
  - `core/bridge/apis/contacts.ts`: `fetchUserProfileByUid(uid)`
    facade method.
  - `core/bridge/bridge.ts`: wire `resolveStrangerProfile` into the
    pipeline deps so it routes through the new namespace.

Tests:
  - UID-form namespace: declares the right cmd/subcmd, packages uid
    as field 1, decodes nickname + uin so the pipeline patch works.
  - Pipeline stranger resolve: patches event.fromUin from the
    resolver response; fails open on null / throw; skips the wire
    call when fromUin is already populated or fromUid is empty.

All 655 tests pass (was 645 + 10 new); typecheck + lint clean.
This commit is contained in:
DrSmoothl
2026-05-23 17:15:59 +08:00
parent 4755a91cb3
commit a4909ec9e5
9 changed files with 415 additions and 3 deletions
+4
View File
@@ -147,6 +147,10 @@
"types": "./src/oidb-services/contacts/fetch-user-profile.ts",
"default": "./src/oidb-services/contacts/fetch-user-profile.ts"
},
"./oidb-services/contacts/fetch-user-profile-by-uid": {
"types": "./src/oidb-services/contacts/fetch-user-profile-by-uid.ts",
"default": "./src/oidb-services/contacts/fetch-user-profile-by-uid.ts"
},
"./oidb-services/contacts/fetch-group-requests": {
"types": "./src/oidb-services/contacts/fetch-group-requests.ts",
"default": "./src/oidb-services/contacts/fetch-group-requests.ts"
@@ -15,7 +15,15 @@ export const decodeGroupJoinRequest: MsgPushDecoder = (ctx) => {
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId: join.groupUin ?? 0,
fromUin: resolveUidToUin(ctx.identity, join.groupUin ?? 0, join.targetUid ?? '', ctx.fromUin),
// Strangers (not in our cache, not yet group members) won't resolve
// here — pass 0 instead of ctx.fromUin (which on a group-scoped push
// IS the group's own uin, so falling back to it produces a bogus
// `fromUin === groupId` collision). The packet-pipeline's pre-
// dispatch hook does an async UID-form FetchUserProfile lookup to
// fill in the uin + nickname before the event is emitted — same
// shape as Lagrange's flow at
// `dev/Lagrange.Core/.../MessagingLogic.cs:215-224`.
fromUin: resolveUidToUin(ctx.identity, join.groupUin ?? 0, join.targetUid ?? '', 0),
fromUid: join.targetUid ?? '',
subType: 'add',
message: '',
@@ -33,7 +41,7 @@ export const decodeGroupInvitation: MsgPushDecoder = (ctx) => {
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId: inner.groupUin ?? 0,
fromUin: resolveUidToUin(ctx.identity, inner.groupUin ?? 0, inner.invitorUid ?? '', ctx.fromUin),
fromUin: resolveUidToUin(ctx.identity, inner.groupUin ?? 0, inner.invitorUid ?? '', 0),
fromUid: inner.invitorUid ?? '',
subType: 'invite',
message: '',
@@ -50,7 +58,7 @@ export const decodeGroupInvite: MsgPushDecoder = (ctx) => {
time: ctx.head.timestamp,
selfUin: ctx.selfUin,
groupId: invite.groupUin ?? 0,
fromUin: resolveUidToUin(ctx.identity, invite.groupUin ?? 0, invite.invitorUid ?? '', ctx.fromUin),
fromUin: resolveUidToUin(ctx.identity, invite.groupUin ?? 0, invite.invitorUid ?? '', 0),
fromUid: invite.invitorUid ?? '',
subType: 'invite',
message: '',
@@ -0,0 +1,99 @@
// 0xFE1_2 (UID form) — fetch a user's profile when only the UID is
// known. Same OIDB cmd + subcmd as `FetchUserProfile` (by UIN) but
// the request body's field 1 is `uid: string` instead of `uin:
// uint32`, and `reserved` stays 0 (no `uinForm`).
//
// Why two namespaces for the same (cmd, subcmd):
// - Group join requests / invitations carry the stranger's UID
// only — we never get a UIN for someone who hasn't joined yet.
// - The Tencent server WILL reject a UIN-form request with a uid-
// shaped value or vice versa, so the two variants must encode
// to byte-different wires.
// - Matches Lagrange's `OidbSvcTrpcTcp0xFE1_2Uid` vs `…Uin` split
// at `dev/Lagrange.Core/.../OidbSvcTrpcTcp0xFE1_2.cs:9-26`
// and the dispatch at
// `dev/Lagrange.Core/.../FetchUserInfoService.cs:66-78`.
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
AvatarInfo,
OidbUserInfoByUidRequest,
OidbUserInfoResponse,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { UserProfileInfo } from '../../qq-info';
import { invokeOidb, type OidbSender } from '../../oidb-service';
const REQUESTED_KEYS = [
20002, 27394, 20009, 20031, 101, 103, 102, 20020, 20003, 20026,
105, 27372, 27406, 20037,
];
export namespace FetchUserProfileByUid {
export const command = 0xFE1;
export const subCommand = 2;
// No `uinForm` — the UID variant uses the default `reserved=0`
// envelope, mirroring Lagrange's untagged constructor at
// `OidbSvcTrpcTcp0xFE1_2.cs:9` (isUid=false → Reserved=0).
export interface Params {
uid: string;
}
export type Deps = OidbSender;
export const serialize = (_ctx: Deps, p: Params): OidbUserInfoByUidRequest => ({
uid: p.uid,
keys: REQUESTED_KEYS.map(k => ({ key: k })),
});
export const deserialize = (_ctx: Deps, body: OidbUserInfoResponse): UserProfileInfo => {
if (!body.body) throw new Error('user info response body missing');
const info: UserProfileInfo = {
uin: body.body.uin ?? 0,
uid: body.body.uid ?? '',
nickname: '', remark: '', qid: '', sex: 'unknown', age: 0, sign: '', avatar: '', level: 0,
};
if (body.body.properties) {
const bytesMap = new Map<number, Uint8Array>();
const numMap = new Map<number, number>();
for (const bp of body.body.properties.bytesProperties ?? []) {
bytesMap.set(bp.code ?? 0, bp.value ?? new Uint8Array(0));
}
for (const np of body.body.properties.numberProperties ?? []) {
numMap.set(np.number1 ?? 0, np.number2 ?? 0);
}
const getString = (code: number): string => {
const b = bytesMap.get(code);
return b ? Buffer.from(b).toString('utf8') : '';
};
info.nickname = getString(20002);
info.remark = getString(103);
info.qid = getString(27394);
info.sign = getString(102);
const avatarBytes = bytesMap.get(101);
if (avatarBytes) {
const av = protobuf_decode<AvatarInfo>(avatarBytes);
if (av?.url) info.avatar = av.url + '640';
}
const sexNum = numMap.get(20009) ?? 0;
info.sex = sexNum === 1 ? 'male' : sexNum === 2 ? 'female' : 'unknown';
info.age = numMap.get(20037) ?? 0;
info.level = numMap.get(105) ?? 0;
}
return info;
};
export const encode = (env: OidbBase<OidbUserInfoByUidRequest>): Uint8Array =>
protobuf_encode<OidbBase<OidbUserInfoByUidRequest>>(env);
export const decode = (bytes: Uint8Array): OidbBase<OidbUserInfoResponse> =>
protobuf_decode<OidbBase<OidbUserInfoResponse>>(bytes);
export const invoke = (deps: Deps, params: Params): Promise<UserProfileInfo> =>
invokeOidb(deps, FetchUserProfileByUid, params);
}
+45
View File
@@ -33,6 +33,16 @@ export interface PacketPipelineDeps {
* and `refreshGroupList` was false).
*/
refreshMemberCache(groupId: number, refreshGroupList: boolean, forceMemberList: boolean): Promise<boolean>;
/**
* Resolve a stranger profile by UID — used to fill in the requester's
* uin + nickname on group-join-request and friend-request events
* where the push only carries a uid. Mirrors Lagrange's
* `FetchUserInfoEvent.Create(targetUid)` path
* (`dev/Lagrange.Core/.../MessagingLogic.cs:215-224`). Returns null
* on lookup failure so the dispatch path can proceed with the bare
* uid-only event.
*/
resolveStrangerProfile(uid: string): Promise<{ uin: number; nickname: string } | null>;
}
export class IncomingPacketPipeline {
@@ -74,6 +84,11 @@ export class IncomingPacketPipeline {
this.log.warn('dispatchAfterIdentityRefresh failed: %s',
err instanceof Error ? (err.stack ?? err.message) : String(err));
});
} else if (this.needsStrangerResolve(event)) {
void this.dispatchAfterStrangerResolve(event).catch((err) => {
this.log.warn('dispatchAfterStrangerResolve failed: %s',
err instanceof Error ? (err.stack ?? err.message) : String(err));
});
} else {
this.handleSideEffects(event);
printEvent(this.eventLog, this.deps.identity, event);
@@ -96,6 +111,15 @@ export class IncomingPacketPipeline {
return event.kind === 'group_member_join' && event.groupId > 0 && event.userUin <= 0 && Boolean(event.userUid);
}
/** Group join requests carry only the requester's UID (no UIN). We
* fire a UID-form FetchUserProfile to resolve UIN + nickname before
* dispatch so the OneBot layer's `user_id` field is populated and
* the consumer bot's follow-up `get_stranger_info` lookup succeeds.
* Mirrors Lagrange's `dev/Lagrange.Core/.../MessagingLogic.cs:215-224`. */
private needsStrangerResolve(event: QQEventVariant): event is Extract<QQEventVariant, { kind: 'group_invite' }> {
return event.kind === 'group_invite' && event.fromUin <= 0 && Boolean(event.fromUid);
}
private async dispatchAfterIdentityRefresh(event: Extract<QQEventVariant, { kind: 'group_member_join' }>): Promise<void> {
let refreshed = false;
try {
@@ -110,6 +134,27 @@ export class IncomingPacketPipeline {
this.emit(event);
}
private async dispatchAfterStrangerResolve(event: Extract<QQEventVariant, { kind: 'group_invite' }>): Promise<void> {
const uid = event.fromUid;
try {
if (uid) {
const profile = await this.deps.resolveStrangerProfile(uid);
if (profile && profile.uin > 0) {
event.fromUin = profile.uin;
}
}
} catch (e) {
// Fail open — emit the event with the bare uid so bots can at
// least see the join request, even if they can't render a name.
this.log.warn('failed to resolve stranger profile: uid=%s err=%s',
uid ?? '', e instanceof Error ? e.message : String(e));
}
this.handleSideEffects(event);
printEvent(this.eventLog, this.deps.identity, event);
this.emit(event);
}
private async prepareGroupMemberJoinIdentity(event: Extract<QQEventVariant, { kind: 'group_member_join' }>): Promise<boolean> {
this.resolveMemberIdentityFromCache(event);
if (event.userUin > 0 || !event.userUid || event.groupId <= 0) return false;
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { OidbBase } from '@snowluma/proto-defs/oidb';
import type {
OidbUserInfoByUidRequest, OidbUserInfoResponse,
} from '@snowluma/proto-defs/oidb-actions/base';
import type { SendPacketResult } from '@snowluma/common/packet-sender';
import { FetchUserProfileByUid } from '../../../src/oidb-services/contacts/fetch-user-profile-by-uid';
function makeSender(body?: OidbUserInfoResponse) {
const responseData = body !== undefined
? Buffer.from(protobuf_encode<OidbBase<OidbUserInfoResponse>>({ body }))
: Buffer.alloc(0);
const r: SendPacketResult = { success: true, gotResponse: true, errorCode: 0, errorMessage: '', responseData };
return { sendRawPacket: vi.fn(async () => r) };
}
describe('FetchUserProfileByUid namespace (UID form)', () => {
it('declares 0xFE1_2 WITHOUT uinForm (reserved stays 0)', () => {
expect(FetchUserProfileByUid.command).toBe(0xFE1);
expect(FetchUserProfileByUid.subCommand).toBe(2);
// No `uinForm` exposed — matches Lagrange's isUid=false path on
// the UID variant. Important because the server distinguishes
// UIN vs UID requests via the envelope's `reserved` field.
expect((FetchUserProfileByUid as any).uinForm).toBeUndefined();
});
it('packages the uid as field 1 (string), routes to OidbSvcTrpcTcp.0xfe1_2', async () => {
const sender = makeSender({ body: { uin: 950929451, uid: 'u_abc' } as any });
await FetchUserProfileByUid.invoke(sender, { uid: 'u_abc' });
const [wireName, bytes] = sender.sendRawPacket.mock.calls[0]!;
expect(wireName).toBe('OidbSvcTrpcTcp.0xfe1_2');
const env = protobuf_decode<OidbBase<OidbUserInfoByUidRequest>>(bytes);
expect(env.body?.uid).toBe('u_abc');
// reserved=0 (default, omitted on wire) because this is the UID
// variant — Lagrange's `OidbSvcTrpcTcp0xFE1_2.cs:9` does the same.
expect(env.reserved ?? 0).toBe(0);
});
it('requests the same property key catalogue as the UIN variant', async () => {
const sender = makeSender({ body: { uin: 1, uid: 'u' } as any });
await FetchUserProfileByUid.invoke(sender, { uid: 'u' });
const [, bytes] = sender.sendRawPacket.mock.calls[0]!;
const env = protobuf_decode<OidbBase<OidbUserInfoByUidRequest>>(bytes);
const keys = env.body?.keys?.map(k => k.key);
expect(keys).toContain(20002); // nickname — the whole point
expect(keys).toContain(105); // QQ level
});
it('decodes nickname + uin from the response so the pipeline can fill them into the event', async () => {
// This is the actual point of the namespace — the stranger's
// nickname + uin lands here so the pipeline can patch them onto
// the in-flight group_invite event.
const enc = (s: string) => new TextEncoder().encode(s);
const sender = makeSender({
body: {
uin: 950929451, uid: 'u_abc',
properties: {
bytesProperties: [{ code: 20002, value: enc('小明') }],
numberProperties: [],
},
} as any,
});
const out = await FetchUserProfileByUid.invoke(sender, { uid: 'u_abc' });
expect(out.uin).toBe(950929451);
expect(out.nickname).toBe('小明');
});
it('throws when the response body is missing entirely', async () => {
const sender = makeSender({});
await expect(FetchUserProfileByUid.invoke(sender, { uid: 'u_abc' }))
.rejects.toThrow('user info response body missing');
});
});
@@ -0,0 +1,152 @@
// Pre-dispatch stranger resolve regression — when a group join
// request comes in, the push only carries the requester's UID. The
// pipeline must do an async UID-form FetchUserProfile lookup BEFORE
// emitting the event so the OneBot layer's `user_id` field is
// populated and the consumer bot's `get_stranger_info` lookup works.
//
// Cross-checked against Lagrange's flow:
// dev/Lagrange.Core/.../MessagingLogic.cs:215-224
// (FetchUserInfoEvent by uid → resolved uin → posted event)
import { describe, expect, it, vi } from 'vitest';
import { IncomingPacketPipeline } from '@snowluma/bridge/packet-pipeline';
import { BridgeEventBus } from '@snowluma/bridge/event-bus';
import { IdentityService } from '@snowluma/bridge/identity-service';
import type { QQEventVariant } from '@snowluma/bridge/events';
import type { PacketInfo } from '@snowluma/common/protocol-types';
function makePipeline(opts: {
resolveStrangerProfile?: vi.Mock;
} = {}) {
const identity = IdentityService.memory('10001');
const events = new BridgeEventBus();
const resolveStrangerProfile = opts.resolveStrangerProfile ?? vi.fn(async () => null);
const pipeline = new IncomingPacketPipeline({
identity,
events,
refreshMemberCache: vi.fn(async () => false),
resolveStrangerProfile,
});
const captured: QQEventVariant[] = [];
events.onAny((event) => { captured.push(event as QQEventVariant); });
return { pipeline, events, captured, resolveStrangerProfile };
}
describe('IncomingPacketPipeline / stranger resolve on group_invite', () => {
it('calls resolveStrangerProfile(uid) when group_invite has fromUin=0 + fromUid', async () => {
const resolveStrangerProfile = vi.fn(async (_uid: string) => ({
uin: 950929451, nickname: '小明',
}));
const { pipeline, captured } = makePipeline({ resolveStrangerProfile });
// Plant a parser that emits the user's exact bug-report shape:
// groupId set, fromUid is a string UID, fromUin=0 (decoder fix).
pipeline.registerCmd('test.cmd', () => [{
kind: 'group_invite',
time: 1700000000,
selfUin: 10001,
groupId: 123456789,
fromUin: 0,
fromUid: 'u_stranger_abc',
subType: 'add',
message: '',
flag: 'add:123456789:u_stranger_abc',
} as QQEventVariant]);
pipeline.process({ serviceCmd: 'test.cmd' } as PacketInfo);
// Let the async hook flush.
await new Promise(r => setTimeout(r, 10));
expect(resolveStrangerProfile).toHaveBeenCalledWith('u_stranger_abc');
expect(captured).toHaveLength(1);
const event = captured[0] as Extract<QQEventVariant, { kind: 'group_invite' }>;
expect(event.fromUin).toBe(950929451); // ← patched in by the async resolve
expect(event.fromUid).toBe('u_stranger_abc');
expect(event.groupId).toBe(123456789);
});
it('still emits the event when resolveStrangerProfile returns null (fail open)', async () => {
const resolveStrangerProfile = vi.fn(async () => null);
const { pipeline, captured } = makePipeline({ resolveStrangerProfile });
pipeline.registerCmd('test.cmd', () => [{
kind: 'group_invite',
time: 1, selfUin: 10001,
groupId: 12345, fromUin: 0, fromUid: 'u_no_such_user',
subType: 'add', message: '', flag: 'add:12345:u_no_such_user',
} as QQEventVariant]);
pipeline.process({ serviceCmd: 'test.cmd' } as PacketInfo);
await new Promise(r => setTimeout(r, 10));
expect(resolveStrangerProfile).toHaveBeenCalledOnce();
expect(captured).toHaveLength(1);
const event = captured[0] as Extract<QQEventVariant, { kind: 'group_invite' }>;
// fromUin stays 0 — but the event still goes out so the bot can
// see the join request and at least respond using the uid in the
// flag.
expect(event.fromUin).toBe(0);
expect(event.fromUid).toBe('u_no_such_user');
});
it('still emits the event when resolveStrangerProfile throws', async () => {
const resolveStrangerProfile = vi.fn(async () => { throw new Error('network down'); });
const { pipeline, captured } = makePipeline({ resolveStrangerProfile });
pipeline.registerCmd('test.cmd', () => [{
kind: 'group_invite',
time: 1, selfUin: 10001,
groupId: 12345, fromUin: 0, fromUid: 'u_x',
subType: 'add', message: '', flag: 'add:12345:u_x',
} as QQEventVariant]);
pipeline.process({ serviceCmd: 'test.cmd' } as PacketInfo);
await new Promise(r => setTimeout(r, 10));
expect(captured).toHaveLength(1);
expect(captured[0]).toMatchObject({ fromUin: 0, fromUid: 'u_x' });
});
it('skips the stranger resolve when fromUin is already populated (cache hit)', async () => {
// If the decoder's `resolveUidToUin` already found the uin in
// the local cache (e.g. group member roster), we shouldn't waste
// a wire call.
const resolveStrangerProfile = vi.fn(async () => null);
const { pipeline, captured } = makePipeline({ resolveStrangerProfile });
pipeline.registerCmd('test.cmd', () => [{
kind: 'group_invite',
time: 1, selfUin: 10001,
groupId: 12345, fromUin: 99999, fromUid: 'u_known',
subType: 'invite', message: '', flag: 'invite:12345:u_known',
} as QQEventVariant]);
pipeline.process({ serviceCmd: 'test.cmd' } as PacketInfo);
await new Promise(r => setTimeout(r, 10));
expect(resolveStrangerProfile).not.toHaveBeenCalled();
expect(captured).toHaveLength(1);
expect(captured[0]).toMatchObject({ fromUin: 99999, fromUid: 'u_known' });
});
it('skips the stranger resolve when fromUid is empty (no uid to look up by)', async () => {
const resolveStrangerProfile = vi.fn(async () => null);
const { pipeline, captured } = makePipeline({ resolveStrangerProfile });
pipeline.registerCmd('test.cmd', () => [{
kind: 'group_invite',
time: 1, selfUin: 10001,
groupId: 12345, fromUin: 0, fromUid: '',
subType: 'add', message: '', flag: 'add:12345:',
} as QQEventVariant]);
pipeline.process({ serviceCmd: 'test.cmd' } as PacketInfo);
await new Promise(r => setTimeout(r, 10));
expect(resolveStrangerProfile).not.toHaveBeenCalled();
expect(captured).toHaveLength(1);
});
});
+11
View File
@@ -18,6 +18,7 @@ import { FetchFriendListPage } from '@snowluma/bridge/oidb-services/contacts/fet
import { FetchGroupList } from '@snowluma/bridge/oidb-services/contacts/fetch-group-list';
import { FetchGroupMemberListPage } from '@snowluma/bridge/oidb-services/contacts/fetch-group-member-list-page';
import { FetchUserProfile } from '@snowluma/bridge/oidb-services/contacts/fetch-user-profile';
import { FetchUserProfileByUid } from '@snowluma/bridge/oidb-services/contacts/fetch-user-profile-by-uid';
import { FetchGroupRequests } from '@snowluma/bridge/oidb-services/contacts/fetch-group-requests';
import { FetchDownloadRkeys } from '@snowluma/bridge/oidb-services/contacts/fetch-download-rkeys';
@@ -154,6 +155,16 @@ export class ContactsApi {
return info;
}
/** Look up a user profile by UID (string form). Used for strangers
* whose UIN we don't have yet — typically the requester on a
* group join request push. Mirrors Lagrange's `FetchUserInfoEvent
* .Create(targetUid)` path. */
async fetchUserProfileByUid(uid: string): Promise<UserProfileInfo> {
const info = await FetchUserProfileByUid.invoke(this.ctx, { uid });
if (info.uin > 0) this.ctx.identity.rememberUserProfile(info);
return info;
}
async fetchGroupRequests(filtered = false): Promise<GroupRequestInfo[]> {
const resp = await FetchGroupRequests.invoke(this.ctx, { filtered });
const requests: GroupRequestInfo[] = [];
+9
View File
@@ -147,6 +147,15 @@ export class Bridge implements BridgeInterface {
events: this.events,
refreshMemberCache: (groupId, refreshGroupList, forceMemberList) =>
this.refreshMemberCache(groupId, refreshGroupList, forceMemberList),
resolveStrangerProfile: async (uid) => {
try {
const p = await this.apis.contacts.fetchUserProfileByUid(uid);
if (p.uin <= 0) return null;
return { uin: p.uin, nickname: p.nickname };
} catch {
return null;
}
},
});
this.pipeline.registerCmd(MSG_PUSH_CMD, parseMsgPush);
}
@@ -129,6 +129,15 @@ export interface OidbUserInfoRequest {
uin?: pb<1, uint_32>;
keys?: pb_repeated<3, OidbUserInfoKey>;
}
// UID-form variant of OIDB 0xFE1_2 — same wire shape but field 1 is
// the uid string. Used by the stranger lookup path (group join
// requests / friend requests) because the push only carries a uid.
// Matches Lagrange's `OidbSvcTrpcTcp0xFE1_2Uid`:
// dev/Lagrange.Core/.../OidbSvcTrpcTcp0xFE1_2.cs:9-16
export interface OidbUserInfoByUidRequest {
uid?: pb<1, string>;
keys?: pb_repeated<3, OidbUserInfoKey>;
}
export interface OidbTwoNumber {
number1?: pb<1, uint_32>;
number2?: pb<2, uint_32>;