diff --git a/packages/bridge/package.json b/packages/bridge/package.json index fb1f14cc..14e0967c 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -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" diff --git a/packages/bridge/src/msg-push/decoders/group-join-request.ts b/packages/bridge/src/msg-push/decoders/group-join-request.ts index 67d87aea..4ca79ed4 100644 --- a/packages/bridge/src/msg-push/decoders/group-join-request.ts +++ b/packages/bridge/src/msg-push/decoders/group-join-request.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: '', diff --git a/packages/bridge/src/oidb-services/contacts/fetch-user-profile-by-uid.ts b/packages/bridge/src/oidb-services/contacts/fetch-user-profile-by-uid.ts new file mode 100644 index 00000000..2d046e9e --- /dev/null +++ b/packages/bridge/src/oidb-services/contacts/fetch-user-profile-by-uid.ts @@ -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(); + const numMap = new Map(); + 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(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): Uint8Array => + protobuf_encode>(env); + + export const decode = (bytes: Uint8Array): OidbBase => + protobuf_decode>(bytes); + + export const invoke = (deps: Deps, params: Params): Promise => + invokeOidb(deps, FetchUserProfileByUid, params); +} diff --git a/packages/bridge/src/packet-pipeline.ts b/packages/bridge/src/packet-pipeline.ts index 89861ee3..a4fbf2b2 100644 --- a/packages/bridge/src/packet-pipeline.ts +++ b/packages/bridge/src/packet-pipeline.ts @@ -33,6 +33,16 @@ export interface PacketPipelineDeps { * and `refreshGroupList` was false). */ refreshMemberCache(groupId: number, refreshGroupList: boolean, forceMemberList: boolean): Promise; + /** + * 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 { + return event.kind === 'group_invite' && event.fromUin <= 0 && Boolean(event.fromUid); + } + private async dispatchAfterIdentityRefresh(event: Extract): Promise { let refreshed = false; try { @@ -110,6 +134,27 @@ export class IncomingPacketPipeline { this.emit(event); } + private async dispatchAfterStrangerResolve(event: Extract): Promise { + 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): Promise { this.resolveMemberIdentityFromCache(event); if (event.userUin > 0 || !event.userUid || event.groupId <= 0) return false; diff --git a/packages/bridge/tests/oidb-services/contacts/fetch-user-profile-by-uid.test.ts b/packages/bridge/tests/oidb-services/contacts/fetch-user-profile-by-uid.test.ts new file mode 100644 index 00000000..3100d552 --- /dev/null +++ b/packages/bridge/tests/oidb-services/contacts/fetch-user-profile-by-uid.test.ts @@ -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>({ 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>(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>(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'); + }); +}); diff --git a/packages/bridge/tests/packet-pipeline-stranger-resolve.test.ts b/packages/bridge/tests/packet-pipeline-stranger-resolve.test.ts new file mode 100644 index 00000000..a9bece69 --- /dev/null +++ b/packages/bridge/tests/packet-pipeline-stranger-resolve.test.ts @@ -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; + 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; + // 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); + }); +}); diff --git a/packages/core/src/bridge/apis/contacts.ts b/packages/core/src/bridge/apis/contacts.ts index 4ca757ee..5ca0498e 100644 --- a/packages/core/src/bridge/apis/contacts.ts +++ b/packages/core/src/bridge/apis/contacts.ts @@ -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 { + const info = await FetchUserProfileByUid.invoke(this.ctx, { uid }); + if (info.uin > 0) this.ctx.identity.rememberUserProfile(info); + return info; + } + async fetchGroupRequests(filtered = false): Promise { const resp = await FetchGroupRequests.invoke(this.ctx, { filtered }); const requests: GroupRequestInfo[] = []; diff --git a/packages/core/src/bridge/bridge.ts b/packages/core/src/bridge/bridge.ts index 98cec6ba..3842073d 100644 --- a/packages/core/src/bridge/bridge.ts +++ b/packages/core/src/bridge/bridge.ts @@ -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); } diff --git a/packages/proto-defs/src/oidb-actions/base.ts b/packages/proto-defs/src/oidb-actions/base.ts index 26ecac70..4a14a6ab 100644 --- a/packages/proto-defs/src/oidb-actions/base.ts +++ b/packages/proto-defs/src/oidb-actions/base.ts @@ -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>;