Skip to content

Commit 4643940

Browse files
committed
Route ephemeral bot commands through private outgoing messages
1 parent ff389ae commit 4643940

5 files changed

Lines changed: 242 additions & 7 deletions

File tree

‎packages/ChatHistoryModel/Package.swift‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,5 +84,13 @@ let package = Package(
8484
.product(name: "AvatarUI", package: "AvatarUI", condition: nil),
8585
],
8686
path: "Sources/ChatHistoryModel"),
87+
.testTarget(
88+
name: "ChatHistoryModelTests",
89+
dependencies: [
90+
"ChatHistoryModel",
91+
.product(name: "TelegramCore", package: "TelegramCore"),
92+
.product(name: "Postbox", package: "Postbox"),
93+
],
94+
path: "Tests/ChatHistoryModelTests"),
8795
]
8896
)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import Foundation
2+
import Postbox
3+
import TelegramCore
4+
5+
struct ResolvedEphemeralBotCommand {
6+
let botPeerId: PeerId
7+
let commandLength: Int
8+
9+
func apply(to messages: [EnqueueMessage]) -> [EnqueueMessage] {
10+
return messages.enumerated().map { index, message in
11+
guard case .message = message else {
12+
return message
13+
}
14+
return message.withUpdatedAttributes { attributes in
15+
var attributes = attributes
16+
if index == 0, commandLength > 0 {
17+
let commandEntity = MessageTextEntity(range: 0 ..< commandLength, type: .BotCommand)
18+
if let index = attributes.firstIndex(where: { $0 is TextEntitiesMessageAttribute }), let attribute = attributes[index] as? TextEntitiesMessageAttribute {
19+
if !attribute.entities.contains(commandEntity) {
20+
attributes[index] = TextEntitiesMessageAttribute(entities: attribute.entities + [commandEntity])
21+
}
22+
} else {
23+
attributes.append(TextEntitiesMessageAttribute(entities: [commandEntity]))
24+
}
25+
}
26+
attributes.append(EphemeralOutgoingMessageAttribute(botPeerId: botPeerId, randomId: 0, state: .sending))
27+
return attributes
28+
}
29+
}
30+
}
31+
}
32+
33+
private struct ParsedBotCommandToken {
34+
let command: String
35+
let botAddressName: String?
36+
let length: Int
37+
}
38+
39+
private func isBotCommandScalar(_ value: unichar) -> Bool {
40+
return (48 ... 57).contains(value) || (65 ... 90).contains(value) || (97 ... 122).contains(value) || value == 95
41+
}
42+
43+
private func parseFirstBotCommandToken(_ text: String) -> ParsedBotCommandToken? {
44+
let text = text as NSString
45+
guard text.length >= 2, text.character(at: 0) == 47 else {
46+
return nil
47+
}
48+
var index = 1
49+
while index < text.length, isBotCommandScalar(text.character(at: index)) {
50+
index += 1
51+
}
52+
guard index > 1 else {
53+
return nil
54+
}
55+
let command = text.substring(with: NSRange(location: 1, length: index - 1))
56+
var botAddressName: String?
57+
if index < text.length, text.character(at: index) == 64 {
58+
let start = index + 1
59+
index = start
60+
while index < text.length, isBotCommandScalar(text.character(at: index)) {
61+
index += 1
62+
}
63+
guard index > start else {
64+
return nil
65+
}
66+
botAddressName = text.substring(with: NSRange(location: start, length: index - start))
67+
}
68+
if index < text.length {
69+
guard let scalar = UnicodeScalar(Int(text.character(at: index))), CharacterSet.whitespacesAndNewlines.contains(scalar) else {
70+
return nil
71+
}
72+
}
73+
return ParsedBotCommandToken(command: command, botAddressName: botAddressName, length: index)
74+
}
75+
76+
func mayContainTypedEphemeralBotCommand(_ text: String) -> Bool {
77+
return parseFirstBotCommandToken(text) != nil
78+
}
79+
80+
func ephemeralBotCommand(botPeerId: PeerId, text: String) -> ResolvedEphemeralBotCommand {
81+
return ResolvedEphemeralBotCommand(botPeerId: botPeerId, commandLength: parseFirstBotCommandToken(text)?.length ?? 0)
82+
}
83+
84+
func resolveEphemeralBotCommand(text: String, peerCommands: PeerCommands) -> ResolvedEphemeralBotCommand? {
85+
guard let parsed = parseFirstBotCommandToken(text) else {
86+
return nil
87+
}
88+
let matches = peerCommands.commands.filter { command in
89+
guard command.command.text == parsed.command else {
90+
return false
91+
}
92+
if let botAddressName = parsed.botAddressName {
93+
return command.peer.addressName?.caseInsensitiveCompare(botAddressName) == .orderedSame
94+
}
95+
return true
96+
}
97+
guard matches.count == 1, let match = matches.first, match.command.isEphemeral else {
98+
return nil
99+
}
100+
return ResolvedEphemeralBotCommand(botPeerId: match.peer.id, commandLength: parsed.length)
101+
}

‎packages/ChatHistoryModel/Sources/ChatHistoryModel/SenderController.swift‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,11 @@ public class Sender: NSObject {
161161
return preview
162162
}
163163

164-
public static func enqueue(input:ChatTextInputState, context: any AccountContextProtocol, peerId:PeerId, replyId:EngineMessageReplySubject?, threadId: Int64?, replyStoryId: StoryId? = nil, disablePreview:Bool = false, linkBelowMessage: Bool = false, largeMedia: Bool? = nil, silent: Bool = false, atDate:Date? = nil, repeatPeriod: Int32? = nil, sendAsPeerId: PeerId? = nil, mediaPreview: TelegramMediaWebpage? = nil, emptyHandler:(()->Void)? = nil, customChatContents: ChatCustomContentsProtocol? = nil, messageEffect: AvailableMessageEffects.MessageEffect? = nil, sendPaidMessageStars: StarsAmount? = nil, suggestPost: ChatInterfaceState.ChannelSuggestPost? = nil) -> Signal<[MessageId?],NoError> {
165-
return enqueue(input: input, context: context, peerId: peerId, replyId: replyId, threadId: threadId, replyStoryId: replyStoryId, disablePreview: disablePreview, linkBelowMessage: linkBelowMessage, largeMedia: largeMedia, silent: silent, atDate: atDate, repeatPeriod: repeatPeriod, sendAsPeerId: sendAsPeerId, mediaPreview: mediaPreview, emptyHandler: emptyHandler, customChatContents: customChatContents, messageEffect: messageEffect, sendPaidMessageStars: sendPaidMessageStars, suggestPost: suggestPost, observedResult: nil)
164+
public static func enqueue(input:ChatTextInputState, context: any AccountContextProtocol, peerId:PeerId, replyId:EngineMessageReplySubject?, threadId: Int64?, replyStoryId: StoryId? = nil, disablePreview:Bool = false, linkBelowMessage: Bool = false, largeMedia: Bool? = nil, silent: Bool = false, atDate:Date? = nil, repeatPeriod: Int32? = nil, sendAsPeerId: PeerId? = nil, mediaPreview: TelegramMediaWebpage? = nil, emptyHandler:(()->Void)? = nil, customChatContents: ChatCustomContentsProtocol? = nil, messageEffect: AvailableMessageEffects.MessageEffect? = nil, sendPaidMessageStars: StarsAmount? = nil, suggestPost: ChatInterfaceState.ChannelSuggestPost? = nil, ephemeralBotPeerId: PeerId? = nil) -> Signal<[MessageId?],NoError> {
165+
return enqueue(input: input, context: context, peerId: peerId, replyId: replyId, threadId: threadId, replyStoryId: replyStoryId, disablePreview: disablePreview, linkBelowMessage: linkBelowMessage, largeMedia: largeMedia, silent: silent, atDate: atDate, repeatPeriod: repeatPeriod, sendAsPeerId: sendAsPeerId, mediaPreview: mediaPreview, emptyHandler: emptyHandler, customChatContents: customChatContents, messageEffect: messageEffect, sendPaidMessageStars: sendPaidMessageStars, suggestPost: suggestPost, ephemeralBotPeerId: ephemeralBotPeerId, observedResult: nil)
166166
}
167167

168-
public static func enqueue(input:ChatTextInputState, context: any AccountContextProtocol, peerId:PeerId, replyId:EngineMessageReplySubject?, threadId: Int64?, replyStoryId: StoryId? = nil, disablePreview:Bool = false, linkBelowMessage: Bool = false, largeMedia: Bool? = nil, silent: Bool = false, atDate:Date? = nil, repeatPeriod: Int32? = nil, sendAsPeerId: PeerId? = nil, mediaPreview: TelegramMediaWebpage? = nil, emptyHandler:(()->Void)? = nil, customChatContents: ChatCustomContentsProtocol? = nil, messageEffect: AvailableMessageEffects.MessageEffect? = nil, sendPaidMessageStars: StarsAmount? = nil, suggestPost: ChatInterfaceState.ChannelSuggestPost? = nil, observedResult: ((Int, [MessageId?]) -> Void)?) -> Signal<[MessageId?],NoError> {
168+
public static func enqueue(input:ChatTextInputState, context: any AccountContextProtocol, peerId:PeerId, replyId:EngineMessageReplySubject?, threadId: Int64?, replyStoryId: StoryId? = nil, disablePreview:Bool = false, linkBelowMessage: Bool = false, largeMedia: Bool? = nil, silent: Bool = false, atDate:Date? = nil, repeatPeriod: Int32? = nil, sendAsPeerId: PeerId? = nil, mediaPreview: TelegramMediaWebpage? = nil, emptyHandler:(()->Void)? = nil, customChatContents: ChatCustomContentsProtocol? = nil, messageEffect: AvailableMessageEffects.MessageEffect? = nil, sendPaidMessageStars: StarsAmount? = nil, suggestPost: ChatInterfaceState.ChannelSuggestPost? = nil, ephemeralBotPeerId: PeerId? = nil, observedResult: ((Int, [MessageId?]) -> Void)?) -> Signal<[MessageId?],NoError> {
169169

170170
var inset:Int = 0
171171
let dynamicEmojiOrder = context.stickerSettings.dynamicPackOrder
@@ -263,7 +263,24 @@ public class Sender: NSObject {
263263

264264
if !mapped.isEmpty {
265265
let inlineMedia = input.inlineMedia.map { $0.key }
266-
return observe(enqueueMessages(account: context.account, peerId: peerId, messages: mapped) |> mapToSignal { value in
266+
let preparedMessages: Signal<[EnqueueMessage], NoError>
267+
if atDate == nil, suggestPost == nil, replyStoryId == nil {
268+
if let ephemeralBotPeerId {
269+
preparedMessages = .single(ephemeralBotCommand(botPeerId: ephemeralBotPeerId, text: input.inputText).apply(to: mapped))
270+
} else if mayContainTypedEphemeralBotCommand(input.inputText) {
271+
let text = input.inputText
272+
preparedMessages = context.engine.peers.peerCommands(id: peerId) |> take(1) |> map { commands in
273+
return resolveEphemeralBotCommand(text: text, peerCommands: commands)?.apply(to: mapped) ?? mapped
274+
}
275+
} else {
276+
preparedMessages = .single(mapped)
277+
}
278+
} else {
279+
preparedMessages = .single(mapped)
280+
}
281+
return observe(preparedMessages |> mapToSignal { messages in
282+
return enqueueMessages(account: context.account, peerId: peerId, messages: messages)
283+
} |> mapToSignal { value in
267284
if !emojis.isEmpty {
268285
let es = saveUsedEmoji(emojis, postbox: context.account.postbox)
269286
let aes = saveAnimatedUsedEmoji(inlineMedia, postbox: context.account.postbox)
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import Foundation
2+
import XCTest
3+
import Postbox
4+
@testable import TelegramCore
5+
@testable import ChatHistoryModel
6+
7+
final class EphemeralBotCommandTests: XCTestCase {
8+
private let firstId = PeerId(namespace: Namespaces.Peer.CloudUser, id: ._internalFromInt64Value(1))
9+
private let secondId = PeerId(namespace: Namespaces.Peer.CloudUser, id: ._internalFromInt64Value(2))
10+
private let chatId = PeerId(namespace: Namespaces.Peer.CloudGroup, id: ._internalFromInt64Value(3))
11+
12+
private func command(_ name: String, botId: PeerId, username: String?, ephemeral: Bool) -> PeerCommand {
13+
let peer = TelegramUser(id: botId, accessHash: nil, firstName: "Bot", lastName: nil, username: username, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: [], emojiStatus: nil, usernames: [], storiesHidden: nil, nameColor: nil, backgroundEmojiId: nil, profileColor: nil, profileBackgroundEmojiId: nil, subscriberCount: nil, verificationIconFileId: nil)
14+
return PeerCommand(peer: EnginePeer(peer), command: BotCommand(text: name, description: name, isEphemeral: ephemeral))
15+
}
16+
17+
private func message(_ text: String, entities: [MessageTextEntity] = []) -> EnqueueMessage {
18+
return .message(text: text, attributes: [TextEntitiesMessageAttribute(entities: entities)], inlineStickers: [:], mediaReference: nil, threadId: 42, replyToMessageId: EngineMessageReplySubject(messageId: MessageId(peerId: chatId, namespace: Namespaces.Message.Cloud, id: 7), quote: nil, innerSubject: nil), replyToStoryId: nil, localGroupingKey: nil, correlationId: 11, bubbleUpEmojiOrStickersets: [])
19+
}
20+
21+
func testResolvesOnlyAdvertisedEphemeralCommands() {
22+
let commands = PeerCommands(commands: [
23+
command("secret", botId: firstId, username: "WhisperBot", ephemeral: true),
24+
command("help", botId: firstId, username: "WhisperBot", ephemeral: false),
25+
])
26+
XCTAssertEqual(resolveEphemeralBotCommand(text: "/secret", peerCommands: commands)?.botPeerId, firstId)
27+
XCTAssertEqual(resolveEphemeralBotCommand(text: "/secret@wHiSpErBoT argument", peerCommands: commands)?.botPeerId, firstId)
28+
for text in ["/help", "/missing", "/secret@OtherBot", "/secret@", "/secret!", "before /secret", "/", "//secret", "/secret😀"] {
29+
XCTAssertNil(resolveEphemeralBotCommand(text: text, peerCommands: commands), text)
30+
}
31+
}
32+
33+
func testAmbiguousBareCommandDoesNotChooseAnotherBot() {
34+
let commands = PeerCommands(commands: [
35+
command("secret", botId: firstId, username: "FirstBot", ephemeral: true),
36+
command("secret", botId: secondId, username: "SecondBot", ephemeral: false),
37+
])
38+
XCTAssertNil(resolveEphemeralBotCommand(text: "/secret", peerCommands: commands))
39+
XCTAssertEqual(resolveEphemeralBotCommand(text: "/secret@FirstBot", peerCommands: commands)?.botPeerId, firstId)
40+
XCTAssertNil(resolveEphemeralBotCommand(text: "/secret@SecondBot", peerCommands: commands))
41+
}
42+
43+
func testArgumentsFormattingReplyAndTopicSurviveRouting() throws {
44+
let text = "/secret@WhisperBot 😀 аргумент\nsecond line"
45+
let bold = MessageTextEntity(range: 21 ..< 29, type: .Bold)
46+
let resolved = ephemeralBotCommand(botPeerId: firstId, text: text)
47+
let messages = resolved.apply(to: [message(text, entities: [bold])])
48+
guard case let .message(sentText, attributes, _, _, threadId, reply, _, _, correlationId, _) = try XCTUnwrap(messages.first) else {
49+
return XCTFail("Expected a text message")
50+
}
51+
XCTAssertEqual(sentText, text)
52+
XCTAssertEqual(threadId, 42)
53+
XCTAssertEqual(reply?.messageId.id, 7)
54+
XCTAssertEqual(correlationId, 11)
55+
let outgoing = try XCTUnwrap(attributes.compactMap { $0 as? EphemeralOutgoingMessageAttribute }.first)
56+
XCTAssertEqual(outgoing.botPeerId, firstId)
57+
XCTAssertEqual(outgoing.randomId, 0)
58+
XCTAssertEqual(outgoing.state, .sending)
59+
let entities = try XCTUnwrap(attributes.compactMap { $0 as? TextEntitiesMessageAttribute }.first).entities
60+
XCTAssertTrue(entities.contains(bold))
61+
XCTAssertTrue(entities.contains(MessageTextEntity(range: 0 ..< 18, type: .BotCommand)))
62+
}
63+
64+
func testEveryChunkRemainsPrivateWithoutRepeatingCommandEntity() throws {
65+
let resolved = ephemeralBotCommand(botPeerId: firstId, text: "/secret argument")
66+
let messages = resolved.apply(to: [message("/secret " + String(repeating: "x", count: 4088)), message("remaining arguments")])
67+
for (index, message) in messages.enumerated() {
68+
guard case let .message(_, attributes, _, _, _, _, _, _, _, _) = message else {
69+
return XCTFail("Expected a text message")
70+
}
71+
let outgoing = attributes.compactMap { $0 as? EphemeralOutgoingMessageAttribute }
72+
XCTAssertEqual(outgoing.count, 1)
73+
XCTAssertEqual(outgoing.first?.botPeerId, firstId)
74+
let entities = try XCTUnwrap(attributes.compactMap { $0 as? TextEntitiesMessageAttribute }.first).entities
75+
XCTAssertEqual(entities.filter { $0.type == .BotCommand }.count, index == 0 ? 1 : 0)
76+
}
77+
}
78+
79+
func testSelectedCommandDoesNotRequireCachedUsernameOrCommandList() throws {
80+
let resolved = ephemeralBotCommand(botPeerId: firstId, text: "/secret")
81+
guard case let .message(_, attributes, _, _, _, _, _, _, _, _) = try XCTUnwrap(resolved.apply(to: [message("/secret")]).first) else {
82+
return XCTFail("Expected a text message")
83+
}
84+
XCTAssertEqual(attributes.compactMap { $0 as? EphemeralOutgoingMessageAttribute }.first?.botPeerId, firstId)
85+
}
86+
87+
func testExistingCommandEntityIsNotDuplicated() throws {
88+
let entity = MessageTextEntity(range: 0 ..< 7, type: .BotCommand)
89+
let resolved = ephemeralBotCommand(botPeerId: firstId, text: "/secret argument")
90+
guard case let .message(_, attributes, _, _, _, _, _, _, _, _) = try XCTUnwrap(resolved.apply(to: [message("/secret argument", entities: [entity])]).first) else {
91+
return XCTFail("Expected a text message")
92+
}
93+
XCTAssertEqual(attributes.compactMap { $0 as? TextEntitiesMessageAttribute }.first?.entities, [entity])
94+
}
95+
96+
func testForwardIsNotConvertedToEphemeralMessage() throws {
97+
let source = MessageId(peerId: chatId, namespace: Namespaces.Message.Cloud, id: 10)
98+
let forward = EnqueueMessage.forward(source: source, threadId: 42, grouping: .none, attributes: [], correlationId: 11)
99+
let resolved = ephemeralBotCommand(botPeerId: firstId, text: "/secret")
100+
guard case let .forward(sentSource, threadId, _, attributes, correlationId) = try XCTUnwrap(resolved.apply(to: [forward]).first) else {
101+
return XCTFail("Expected a forward")
102+
}
103+
XCTAssertEqual(sentSource, source)
104+
XCTAssertEqual(threadId, 42)
105+
XCTAssertEqual(correlationId, 11)
106+
XCTAssertTrue(attributes.isEmpty)
107+
}
108+
}

‎packages/ChatUI/Sources/ChatUI/ChatController.swift‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6887,11 +6887,12 @@ open class ChatController<Context: AccountContextProtocol>: TelegramGenericViewC
68876887
if let strongSelf = self, let peer = strongSelf.chatInteraction.peer, peer.canSendMessage(strongSelf.mode.isThreadMode, threadData: strongSelf.chatInteraction.presentation.threadInfo, cachedData: strongSelf.chatInteraction.presentation.cachedData) {
68886888
func apply(_ controller: ChatController, atDate: Date?, repeatPeriod: Int32?) {
68896889
var commandText = "/" + command.command.text
6890-
if controller.chatInteraction.peerId.namespace != Namespaces.Peer.CloudUser {
6891-
commandText += "@" + (command.peer.addressName ?? "")
6890+
if controller.chatInteraction.peerId.namespace != Namespaces.Peer.CloudUser, let addressName = command.peer.addressName, !addressName.isEmpty {
6891+
commandText += "@" + addressName
68926892
}
68936893
let destinationPeerId = makePeerId()
6894-
_ = Sender.enqueue(input: ChatTextInputState(inputText: commandText), context: context, peerId: destinationPeerId, replyId: takeReplyId(), threadId: threadId64(), atDate: atDate, repeatPeriod: repeatPeriod, customChatContents: customChatContents, sendPaidMessageStars: takePaidMessageStars()).start(completed: scrollAfterSend)
6894+
let ephemeralBotPeerId = command.command.isEphemeral ? command.peer.id : nil
6895+
_ = Sender.enqueue(input: ChatTextInputState(inputText: commandText), context: context, peerId: destinationPeerId, replyId: takeReplyId(), threadId: threadId64(), atDate: atDate, repeatPeriod: repeatPeriod, customChatContents: customChatContents, sendPaidMessageStars: takePaidMessageStars(), ephemeralBotPeerId: ephemeralBotPeerId).start(completed: scrollAfterSend)
68956896
controller.chatInteraction.updateInput(with: "")
68966897
controller.nextTransaction.set(handler: afterSentTransition)
68976898
}

0 commit comments

Comments
 (0)