Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 88 additions & 1 deletion packages/bridge/src/conversation-kinds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1715,20 +1715,107 @@ 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 },
bannedRights: { _: 'chatBannedRights', viewMessages: true, untilDate: 0 },
})
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('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 () => {
Expand Down
76 changes: 70 additions & 6 deletions packages/bridge/src/dialogs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,12 @@ export class DialogRpc {
private readonly _avatarMedia = new Map<string, IMMedia<any>>()
private readonly _memberPages = new Map<string, MemberPageState>()
private readonly _pendingMemberPages = new Map<string, Promise<void>>()
/**
* 平台成员列表不返回禁言状态,所以这里记住经 Telegram 下发的成员禁言(`untilDate`,秒),
* 供 `channels.getParticipant(s)` 把成员投影成受限成员。键是 `conversationId\0userId`;
* 进程重启后丢失,QQ 一侧设置的禁言也不会出现在这里。
*/
private readonly _memberMutes = new Map<string, number>()
private readonly _reactionRefreshFailures = new Map<string, number>()
private _folderEditQueue = Promise.resolve()
private readonly _actions: PlatformMessageActions
Expand Down Expand Up @@ -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)],
}
}
Expand All @@ -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)),
}
Expand All @@ -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)),
}
}
Expand Down Expand Up @@ -2083,15 +2089,27 @@ 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(
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,
}
}
Expand Down Expand Up @@ -5541,7 +5559,10 @@ export class DialogRpc {
: this._makePeerUser(member.user)
}

private _makeChannelParticipant(member: IMConversationMember<any>): tl.TypeChannelParticipant {
private _makeChannelParticipant(
conversationId: string,
member: IMConversationMember<any>,
): tl.TypeChannelParticipant {
const self = member.user.id === this._session.userId
const userId = self ? this._selfId : this._userId(member.user.id)
if (member.role === 'owner') {
Expand All @@ -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 }
}

Expand Down Expand Up @@ -6343,6 +6372,41 @@ export function makeDefaultBannedRights(readOnly = false): tl.RawChatBannedRight
}
}

/** 单个成员的禁言在 Telegram 里是「禁止全部可禁用操作」的 `chatBannedRights`,`untilDate` 为 0 表示永久。 */
function makeMemberMuteRights(untilDate: number): tl.RawChatBannedRights {
const rights = makeDefaultBannedRights(true)
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}`
}

function hasAdminRights(rights: tl.RawChatAdminRights): boolean {
return Object.entries(rights).some(([key, value]) => key !== '_' && value === true)
}
Expand Down
Loading