From 9e0f6b40842ba9cb79868ae755d88c7236010d7a Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:56:24 +0800 Subject: [PATCH 1/2] bridge: project Telegram member mutes as restricted participants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit channels.editBanned 只把禁言下发给平台,随后 channels.getParticipant(s) 仍然 把这个成员投影成普通的 channelParticipant,客户端刷新后看不到任何禁言状态, 看上去就像禁言没生效。记住经 Telegram 下发的禁言并投影成 channelParticipantBanned,同时在该 RPC 的 updates 里带上 updateChannelParticipant,让客户端立刻更新成员状态。 --- .../bridge/src/conversation-kinds.test.ts | 33 ++++++++++++- packages/bridge/src/dialogs.ts | 49 +++++++++++++++++-- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/packages/bridge/src/conversation-kinds.test.ts b/packages/bridge/src/conversation-kinds.test.ts index 6b1e4961..4e064046 100644 --- a/packages/bridge/src/conversation-kinds.test.ts +++ b/packages/bridge/src/conversation-kinds.test.ts @@ -1715,11 +1715,24 @@ describe('conversation kinds', () => { }) const bob = await rpc.userTlId('bob') - await rpc.editChannelBanned({ + const mute = await rpc.editChannelBanned({ _: 'channels.editBanned', channel, participant: { _: 'inputPeerUser', userId: bob, accessHash: Long.ZERO }, bannedRights: { _: 'chatBannedRights', sendMessages: true, untilDate: 1_900_000_000 }, }) + const muted = await rpc.getChannelParticipant({ + _: 'channels.getParticipant', channel, + participant: { _: 'inputPeerUser', userId: bob, accessHash: Long.ZERO }, + }) + const unmute = await rpc.editChannelBanned({ + _: 'channels.editBanned', channel, + participant: { _: 'inputPeerUser', userId: bob, accessHash: Long.ZERO }, + bannedRights: { _: 'chatBannedRights', untilDate: 0 }, + }) + const unmuted = await rpc.getChannelParticipant({ + _: 'channels.getParticipant', channel, + participant: { _: 'inputPeerUser', userId: bob, accessHash: Long.ZERO }, + }) await rpc.editChannelBanned({ _: 'channels.editBanned', channel, participant: { _: 'inputPeerUser', userId: bob, accessHash: Long.ZERO }, @@ -1727,8 +1740,26 @@ describe('conversation kinds', () => { }) expect(moderationCalls).toEqual([ { userId: 'bob', action: { type: 'mute', untilDate: 1_900_000_000 } }, + { userId: 'bob', action: { type: 'unmute' } }, { userId: 'bob', action: { type: 'kick', rejectAddRequest: true } }, ]) + // 平台成员列表不报告禁言,所以桥接自己要把下发的禁言投影回成员状态,否则客户端刷新后又会显示成普通成员。 + expect(mute).toMatchObject({ + updates: [{ + _: 'updateChannelParticipant', channelId: peerTlId('group'), userId: bob, + newParticipant: { _: 'channelParticipantBanned' }, + }], + }) + expect(muted).toMatchObject({ + participant: { + _: 'channelParticipantBanned', + bannedRights: { _: 'chatBannedRights', sendMessages: true, untilDate: 1_900_000_000 }, + }, + }) + expect(unmute).toMatchObject({ + updates: [{ _: 'updateChannelParticipant', newParticipant: { _: 'channelParticipant' } }], + }) + expect(unmuted).toMatchObject({ participant: { _: 'channelParticipant' } }) }) it('serves channel-scoped message, chat-list, and read RPCs', async () => { diff --git a/packages/bridge/src/dialogs.ts b/packages/bridge/src/dialogs.ts index a86b1373..286ef902 100644 --- a/packages/bridge/src/dialogs.ts +++ b/packages/bridge/src/dialogs.ts @@ -292,6 +292,12 @@ export class DialogRpc { private readonly _avatarMedia = new Map>() private readonly _memberPages = new Map() private readonly _pendingMemberPages = new Map>() + /** + * 平台成员列表不返回禁言状态,所以这里记住经 Telegram 下发的成员禁言(`untilDate`,秒), + * 供 `channels.getParticipant(s)` 把成员投影成受限成员。键是 `conversationId\0userId`; + * 进程重启后丢失,QQ 一侧设置的禁言也不会出现在这里。 + */ + private readonly _memberMutes = new Map() private readonly _reactionRefreshFailures = new Map() private _folderEditQueue = Promise.resolve() private readonly _actions: PlatformMessageActions @@ -1976,7 +1982,7 @@ export class DialogRpc { if (!member) throw new RpcError(400, 'USER_NOT_PARTICIPANT') return { _: 'channels.channelParticipant', - participant: this._makeChannelParticipant(member), + participant: this._makeChannelParticipant(conversation.id, member), chats: [this._makeChat(conversation)], users: [this._makeMemberUser(member)], } } @@ -1999,7 +2005,7 @@ export class DialogRpc { || offset + page.members.length + (page.nextCursor ? 1 : 0)) return { _: 'channels.channelParticipants', count, - participants: page.members.map((member) => this._makeChannelParticipant(member)), + participants: page.members.map((member) => this._makeChannelParticipant(conversation.id, member)), chats: [this._makeChat(conversation)], users: page.members.map((member) => this._makeMemberUser(member)), } @@ -2019,7 +2025,7 @@ export class DialogRpc { members = members.slice(offset, offset + limit) return { _: 'channels.channelParticipants', count: total, - participants: members.map((member) => this._makeChannelParticipant(member)), + participants: members.map((member) => this._makeChannelParticipant(conversation.id, member)), chats: [this._makeChat(conversation)], users: members.map((member) => this._makeMemberUser(member)), } } @@ -2089,9 +2095,21 @@ export class DialogRpc { await this._platform.moderateConversationMember( this._session, { id: conversation.id }, targetId, action, ) + const muteKey = memberMuteKey(conversation.id, targetId) + if (action.type === 'mute') this._memberMutes.set(muteKey, action.untilDate) + else this._memberMutes.delete(muteKey) this._memberPages.delete(conversation.id) + // 踢人不在这里推送成员变化:平台的成员列表随后会把这个人去掉。 + const memberUpdates: tl.TypeUpdate[] = action.type === 'kick' + ? [] + : [{ + _: 'updateChannelParticipant', channelId: this._peerId(conversation.id), date: now, + actorId: this._selfId, userId: this._userId(targetId), + newParticipant: this._makeChannelParticipant(conversation.id, target), qts: 0, + }] return { - _: 'updates', updates: [], users: [this._makeMemberUser(target)], + _: 'updates', updates: memberUpdates, + users: [this._makeMemberUser(target)], chats: [this._makeChat(conversation)], date: now, seq: 0, } } @@ -5541,7 +5559,10 @@ export class DialogRpc { : this._makePeerUser(member.user) } - private _makeChannelParticipant(member: IMConversationMember): tl.TypeChannelParticipant { + private _makeChannelParticipant( + conversationId: string, + member: IMConversationMember, + ): tl.TypeChannelParticipant { const self = member.user.id === this._session.userId const userId = self ? this._selfId : this._userId(member.user.id) if (member.role === 'owner') { @@ -5563,6 +5584,14 @@ export class DialogRpc { date: member.joinedAt ?? 0, rank: member.title, } } + const muteUntil = this._memberMutes.get(memberMuteKey(conversationId, member.user.id)) + if (muteUntil && muteUntil > Math.floor(Date.now() / 1000)) { + return { + _: 'channelParticipantBanned', peer: { _: 'peerUser', userId }, + kickedBy: this._selfId, date: member.joinedAt ?? 0, + bannedRights: makeMemberMuteRights(muteUntil), rank: member.title, + } + } return { _: 'channelParticipant', userId, date: member.joinedAt ?? 0, rank: member.title } } @@ -6343,6 +6372,16 @@ export function makeDefaultBannedRights(readOnly = false): tl.RawChatBannedRight } } +/** 单个成员的禁言在 Telegram 里是「禁止全部可禁用操作」的 `chatBannedRights`,`untilDate` 为 0 表示永久。 */ +function makeMemberMuteRights(untilDate: number): tl.RawChatBannedRights { + const rights = makeDefaultBannedRights(true) + return { ...rights, untilDate } +} + +function memberMuteKey(conversationId: string, userId: string): string { + return `${conversationId}\u0000${userId}` +} + function hasAdminRights(rights: tl.RawChatAdminRights): boolean { return Object.entries(rights).some(([key, value]) => key !== '_' && value === true) } From b278dc52091304620ee2fd6daa54161f705842ab Mon Sep 17 00:00:00 2001 From: std-external <201375710+std-external@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:02:00 +0800 Subject: [PATCH 2/2] bridge: read granular send restrictions as a member mute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telegram Desktop 及其衍生客户端不再设置 chatBannedRights.send_messages, 「禁止发言」被拆成 send_plain 和各媒体位。editChannelBanned 之前只看 send_messages,于是每次正常的禁言都被判成解除禁言、给平台下发 unmute, 界面上就显示成「已解除禁言」。改为检查整组发送位,并为过期的 until_date 保留解除语义。 --- .../bridge/src/conversation-kinds.test.ts | 56 +++++++++++++++++++ packages/bridge/src/dialogs.ts | 27 ++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/packages/bridge/src/conversation-kinds.test.ts b/packages/bridge/src/conversation-kinds.test.ts index 4e064046..031741c6 100644 --- a/packages/bridge/src/conversation-kinds.test.ts +++ b/packages/bridge/src/conversation-kinds.test.ts @@ -1762,6 +1762,62 @@ describe('conversation kinds', () => { expect(unmuted).toMatchObject({ participant: { _: 'channelParticipant' } }) }) + it('reads Telegram Desktop granular send restrictions as a mute', async () => { + const moderationCalls: Array<{ userId: string, action: unknown }> = [] + const { rpc } = await createRpc({ + ...platform, + async moderateConversationMember(_session, _conversation, userId, action) { + moderationCalls.push({ userId, action }) + }, + }) + await rpc.getDialogs(dialogsRequest()) + const channel = { _: 'inputChannel' as const, channelId: peerTlId('group'), accessHash: Long.ZERO } + await rpc.getChannelParticipants({ + _: 'channels.getParticipants', channel, + filter: { _: 'channelParticipantsRecent' }, offset: 0, limit: 100, hash: Long.ZERO, + }) + const bob = await rpc.userTlId('bob') + const restrict = (bannedRights: tl.RawChatBannedRights) => rpc.editChannelBanned({ + _: 'channels.editBanned', channel, + participant: { _: 'inputPeerUser', userId: bob, accessHash: Long.ZERO }, + bannedRights, + }) + + // Telegram Desktop 的 Restrict 框只设置 `send_plain` 和各媒体位,从不设置旧的 `send_messages`。 + const granular: tl.RawChatBannedRights = { + _: 'chatBannedRights', + sendPlain: true, sendPhotos: true, sendVideos: true, sendRoundvideos: true, + sendAudios: true, sendVoices: true, sendDocs: true, sendStickers: true, + sendGifs: true, sendGames: true, sendInline: true, embedLinks: true, sendPolls: true, + untilDate: 0, + } + const mute = await restrict(granular) + await restrict({ ...granular, untilDate: 1_900_000_000 }) + expect(moderationCalls.slice(0, 2)).toEqual([ + { userId: 'bob', action: { type: 'mute', untilDate: 0x7fff_ffff } }, + { userId: 'bob', action: { type: 'mute', untilDate: 1_900_000_000 } }, + ]) + expect(mute).toMatchObject({ + updates: [{ + _: 'updateChannelParticipant', + newParticipant: { _: 'channelParticipantBanned', bannedRights: { sendMessages: true, sendPlain: true } }, + }], + }) + expect(await rpc.getChannelParticipant({ + _: 'channels.getParticipant', channel, + participant: { _: 'inputPeerUser', userId: bob, accessHash: Long.ZERO }, + })).toMatchObject({ + participant: { _: 'channelParticipantBanned', bannedRights: { untilDate: 1_900_000_000 } }, + }) + // 全部位都不带 = 解除禁言;`until_date` 已经过去也算解除。 + await restrict({ _: 'chatBannedRights', untilDate: 0 }) + await restrict({ _: 'chatBannedRights', sendPlain: true, untilDate: 1_000 }) + expect(moderationCalls.slice(2)).toEqual([ + { userId: 'bob', action: { type: 'unmute' } }, + { userId: 'bob', action: { type: 'unmute' } }, + ]) + }) + it('serves channel-scoped message, chat-list, and read RPCs', async () => { const { rpc } = await createRpc() await rpc.getDialogs(dialogsRequest()) diff --git a/packages/bridge/src/dialogs.ts b/packages/bridge/src/dialogs.ts index 286ef902..13e51da9 100644 --- a/packages/bridge/src/dialogs.ts +++ b/packages/bridge/src/dialogs.ts @@ -2089,7 +2089,7 @@ export class DialogRpc { const now = Math.floor(Date.now() / 1000) const action: IMConversationMemberModeration = rights.viewMessages ? { type: 'kick', rejectAddRequest: rights.untilDate === 0 } - : rights.sendMessages && (rights.untilDate === 0 || rights.untilDate > now) + : restrictsSending(rights) && (rights.untilDate === 0 || rights.untilDate > now) ? { type: 'mute', untilDate: rights.untilDate || 0x7fff_ffff } : { type: 'unmute' } await this._platform.moderateConversationMember( @@ -6378,6 +6378,31 @@ function makeMemberMuteRights(untilDate: number): tl.RawChatBannedRights { return { ...rights, untilDate } } +/** + * 判断这组 `chatBannedRights` 是否禁止了发言。现代客户端(Telegram Desktop 及其衍生)不再设置旧的 + * `sendMessages` 位,而是把「禁止发言」拆成 `sendPlain` + 各媒体位(`sendPhotos`、`sendVideos`…), + * 只看 `sendMessages` 会把一次禁言当成解除禁言下发。 + */ +function restrictsSending(rights: tl.RawChatBannedRights): boolean { + return Boolean( + rights.sendMessages + || rights.sendMedia + || rights.sendPlain + || rights.sendPhotos + || rights.sendVideos + || rights.sendRoundvideos + || rights.sendAudios + || rights.sendVoices + || rights.sendDocs + || rights.sendStickers + || rights.sendGifs + || rights.sendGames + || rights.sendInline + || rights.embedLinks + || rights.sendPolls, + ) +} + function memberMuteKey(conversationId: string, userId: string): string { return `${conversationId}\u0000${userId}` }