Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
refactor(telegramcore): split gtranslate fallback into SGGTranslate
Move SwiftSoup-based fallback translation helpers into a dedicated Swiftgram module, wire TelegramCore to it, and align message translation/compose AI paths with current upstream API signatures.
  • Loading branch information
Kylmakalle committed Apr 10, 2026
commit b3ae64cc44222b9f4c1baa7e21a785a4ceec80c1
19 changes: 19 additions & 0 deletions Swiftgram/SGGTranslate/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")

swift_library(
name = "SGGTranslate",
module_name = "SGGTranslate",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//Swiftgram/SwiftSoup:SwiftSoup",
],
visibility = [
"//visibility:public",
],
)
173 changes: 173 additions & 0 deletions Swiftgram/SGGTranslate/Sources/SGGTranslate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import Foundation
import SwiftSignalKit
import SwiftSoup

public enum TranslateFetchError {
case network
}

private let sgTranslateSessionConfiguration: URLSessionConfiguration = .ephemeral
private let sgTranslateSession: URLSession = URLSession(configuration: sgTranslateSessionConfiguration)

public func getTranslateUrl(_ message: String, _ toLang: String) -> String {
let sanitizedMessage: String = message
var queryCharSet: CharacterSet = .urlQueryAllowed
queryCharSet.remove(charactersIn: "+&")
return "https://translate.google.com/m?hl=en&tl=\(toLang)&sl=auto&q=\(sanitizedMessage.addingPercentEncoding(withAllowedCharacters: queryCharSet) ?? "")"
}

private func prepareResultString(_ str: String) -> String {
return str
}

public func parseTranslateResponse(_ data: String) -> String {
do {
let document: Document = try SwiftSoup.parse(data)
if let resultContainer: Element = try document.select("div.result-container").first() {
return prepareResultString(try resultContainer.text())
} else if let tZero: Element = try document.select("div.t0").first() {
return prepareResultString(try tZero.text())
}
} catch {
}
return ""
}

public func getGTranslateLang(_ userLang: String) -> String {
var lang: String = userLang
let rawSuffix: String = "-raw"
if lang.hasSuffix(rawSuffix) {
lang = String(lang.dropLast(rawSuffix.count))
}
lang = lang.lowercased()

switch lang {
case "zh-hans", "zh":
return "zh-CN"
case "zh-hant":
return "zh-TW"
case "he":
return "iw"
default:
break
}

lang = lang.components(separatedBy: "-")[0].components(separatedBy: "_")[0]

return lang
}

public func requestTranslateUrl(url: URL) -> Signal<String, TranslateFetchError> {
return Signal { subscriber in
let completed: Atomic<Bool> = Atomic(value: false)
var request: URLRequest = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Mozilla/4.0 (compatible;MSIE 6.0;Windows NT 5.1;SV1;.NET CLR 1.1.4322;.NET CLR 2.0.50727;.NET CLR 3.0.04506.30)", forHTTPHeaderField: "User-Agent")
let downloadTask: URLSessionDataTask = sgTranslateSession.dataTask(with: request, completionHandler: { data, response, _ in
let _ = completed.swap(true)
if let response: HTTPURLResponse = response as? HTTPURLResponse {
if response.statusCode == 200 {
if let data: Data = data {
if let result: String = String(data: data, encoding: .utf8) {
subscriber.putNext(result)
subscriber.putCompletion()
} else {
subscriber.putError(.network)
}
} else {
subscriber.putError(.network)
}
} else {
subscriber.putError(.network)
}
} else {
subscriber.putError(.network)
}
})
downloadTask.resume()

return ActionDisposable {
if !completed.with({ $0 }) {
downloadTask.cancel()
}
}
}
}

public func gtranslate(_ text: String, _ toLang: String) -> Signal<String, TranslateFetchError> {
let lines: [String] = text.components(separatedBy: "\n")

let translationSignals: [Signal<String, TranslateFetchError>] = lines.map { rawLine in
let leadingWhitespace: Substring = rawLine.prefix { $0.isWhitespace }
let core: String = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)

if core.isEmpty {
return .single(rawLine)
}

return gtranslateSentence(core, toLang)
|> map { translatedCore in
return String(leadingWhitespace) + translatedCore
}
}

return combineLatest(translationSignals)
|> map { results in
let joined: String = results.joined(separator: "\n")
return joined.isEmpty ? text : joined
}
}

public func gtranslateSentence(_ text: String, _ toLang: String) -> Signal<String, TranslateFetchError> {
return Signal { subscriber in
let urlString: String = getTranslateUrl(text, getGTranslateLang(toLang))
let url: URL = URL(string: urlString)!
let translateSignal: Signal<String, TranslateFetchError> = requestTranslateUrl(url: url)
var translateDisposable: Disposable?

translateDisposable = translateSignal.start(next: { translatedHtml in
let result: String = parseTranslateResponse(translatedHtml)
if result.isEmpty {
subscriber.putError(.network)
} else {
subscriber.putNext(result)
subscriber.putCompletion()
}
}, error: { _ in
subscriber.putError(.network)
})

return ActionDisposable {
translateDisposable?.dispose()
}
}
}

public func gtranslateSplitTextBySentences(_ text: String, maxChunkLength: Int = 1500) -> [String] {
if text.count <= maxChunkLength {
return [text]
}
var chunks: [String] = []
var currentChunk: String = ""

text.enumerateSubstrings(in: text.startIndex ..< text.endIndex, options: .bySentences) { substring, _, _, _ in
guard let sentence: String = substring else {
return
}

if currentChunk.count + sentence.count + 1 < maxChunkLength {
currentChunk += sentence + " "
} else {
if !currentChunk.isEmpty {
chunks.append(currentChunk.trimmingCharacters(in: .whitespacesAndNewlines))
}
currentChunk = sentence + " "
}
}

if !currentChunk.isEmpty {
chunks.append(currentChunk.trimmingCharacters(in: .whitespacesAndNewlines))
}

return chunks
}
2 changes: 1 addition & 1 deletion submodules/TelegramCore/BUILD
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")

sgdeps = [
"//Swiftgram/SwiftSoup:SwiftSoup",
"//Swiftgram/SGGTranslate:SGGTranslate",
"//Swiftgram/SGSimpleSettings:SGSimpleSettings",
"//Swiftgram/SGTranslationLangFix:SGTranslationLangFix",
"//Swiftgram/SGWebSettingsScheme:SGWebSettingsScheme",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import SGGTranslate
import SGSimpleSettings
import Foundation
import SwiftSignalKit
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
#if DEBUG
import SGSimpleSettings
#endif
import SGGTranslate
import SGTranslationLangFix
import SwiftSoup

import Foundation
import Postbox
Expand Down Expand Up @@ -88,7 +85,7 @@ func _internal_composeMessageWithAI(network: Network, text: String, entities: [M
if emojify {
flags |= (1 << 3)
}

let apiText: Api.TextWithEntities = .textWithEntities(.init(text: text, entities: apiEntitiesFromMessageTextEntities(entities, associatedPeers: SimpleDictionary())))

return network.request(Api.functions.messages.composeMessageWithAI(flags: flags, text: apiText, translateToLang: translateToLang, changeTone: changeTone))
Expand Down Expand Up @@ -264,7 +261,7 @@ private func _internal_translateMessagesByPeerId(account: Account, peerId: Engin
}
}
} else {
msgs = account.network.request(Api.functions.messages.translateText(flags: flags, peer: inputPeer, id: id, text: nil, toLang: toLang))
msgs = account.network.request(Api.functions.messages.translateText(flags: flags, peer: inputPeer, id: id, text: nil, toLang: toLang, tone: tone.rawValue))
|> map(Optional.init)
|> mapError { error -> TranslationError in
if error.errorDescription.hasPrefix("FLOOD_WAIT") {
Expand Down Expand Up @@ -449,3 +446,146 @@ func _internal_togglePeerMessagesTranslationHidden(account: Account, peerId: Eng
|> ignoreValues
}
}

public enum TelegramComposeAIMessageMode {
public enum StyleId: Hashable {
case neutral
case style(String)
}

public struct Style: Equatable {
public let type: String
public let title: String
public let emojiFileId: Int64?

public var id: StyleId {
return .style(self.type)
}

public init(type: String, title: String, emojiFileId: Int64?) {
self.type = type
self.title = title
self.emojiFileId = emojiFileId
}
}

case translate(toLanguage: String, emojify: Bool, style: StyleId)
case stylize(emojify: Bool, style: StyleId)
case proofread
}

public final class TelegramAIComposeMessageResult {
public let text: TextWithEntities
public let diffRanges: [Range<Int>]

public init(text: TextWithEntities, diffRanges: [Range<Int>]) {
self.text = text
self.diffRanges = diffRanges
}
}

extension TextWithEntities {
init(apiValue: Api.TextWithEntities) {
switch apiValue {
case let .textWithEntities(textWithEntities):
self.init(text: textWithEntities.text, entities: messageTextEntitiesFromApiEntities(textWithEntities.entities))
}
}
}

func _internal_composeAIMessageStyles(account: Account) -> Signal<[TelegramComposeAIMessageMode.Style], NoError> {
return account.postbox.transaction { transaction -> [TelegramComposeAIMessageMode.Style] in
var result: [TelegramComposeAIMessageMode.Style] = []
if let data = currentAppConfiguration(transaction: transaction).data, let value = data["ai_compose_styles"] as? [[String]] {
for item in value {
if item.count >= 3 {
let emojiFileId: Int64? = Int64(item[1])
result.append(TelegramComposeAIMessageMode.Style(type: item[0], title: item[2], emojiFileId: emojiFileId))
}
}
}
return result
}
}

public enum TelegramAIComposeMessageError {
case generic
case nonPremiumFlood
}

func _internal_composeAIMessage(account: Account, text: TextWithEntities, mode: TelegramComposeAIMessageMode) -> Signal<TelegramAIComposeMessageResult, TelegramAIComposeMessageError> {
var flags: Int32 = 0
var proofread: Bool = false
var emojify: Bool = false
var translateToLang: String?
var changeTone: String?

switch mode {
case let .translate(toLanguage, emojifyValue, style):
translateToLang = toLanguage
emojify = emojifyValue
if case let .style(name) = style {
changeTone = name
}
case let .stylize(emojifyValue, style):
emojify = emojifyValue
if case let .style(name) = style {
changeTone = name
}
case .proofread:
proofread = true
}

if proofread {
flags |= (1 << 0)
}
if translateToLang != nil {
flags |= (1 << 1)
}
if changeTone != nil {
flags |= (1 << 2)
}
if emojify {
flags |= (1 << 3)
}

let inputText: Api.TextWithEntities = .textWithEntities(Api.TextWithEntities.Cons_textWithEntities(text: text.text, entities: apiEntitiesFromMessageTextEntities(text.entities, associatedPeers: SimpleDictionary())))

return account.network.request(Api.functions.messages.composeMessageWithAI(flags: flags, text: inputText, translateToLang: translateToLang, changeTone: changeTone))
|> `catch` { error -> Signal<Api.messages.ComposedMessageWithAI, TelegramAIComposeMessageError> in
if error.errorDescription == "AICOMPOSE_FLOOD_PREMIUM" || error.errorDescription.hasPrefix("AICOMPOSE_FLOOD_PREMIUM ") {
return .fail(.nonPremiumFlood)
} else {
return .fail(.generic)
}
}
|> mapToSignal { result -> Signal<TelegramAIComposeMessageResult, TelegramAIComposeMessageError> in
switch result {
case let .composedMessageWithAI(composedMessageWithAI):
var diffRanges: [Range<Int>] = []
if let diffText = composedMessageWithAI.diffText {
switch diffText {
case let .textWithEntities(textWithEntities):
for entity in textWithEntities.entities {
switch entity {
case let .messageEntityDiffReplace(messageEntityDiffReplace):
if messageEntityDiffReplace.length >= 0 {
diffRanges.append(Int(messageEntityDiffReplace.offset) ..< Int(messageEntityDiffReplace.offset + messageEntityDiffReplace.length))
}
case let .messageEntityDiffInsert(messageEntityDiffInsert):
if messageEntityDiffInsert.length >= 0 {
diffRanges.append(Int(messageEntityDiffInsert.offset) ..< Int(messageEntityDiffInsert.offset + messageEntityDiffInsert.length))
}
default:
break
}
}
}
}
return .single(TelegramAIComposeMessageResult(
text: TextWithEntities(apiValue: composedMessageWithAI.resultText),
diffRanges: diffRanges
))
}
}
}