diff --git a/README.md b/README.md index 5583fad0b5..903b10185c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ SimpleX logo -Invest in SimpleX Chat. [Learn more on Wefunder](https://wefunder.com/simplexchat). +Invest in SimpleX Chat. [Learn more on Wefunder](https://wefunder.com/simplex.chat?utm_source=github). # SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design! diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 40b88ec338..db70511a5a 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -21,6 +21,7 @@ enum ChatCommand: ChatCmdProtocol { case apiSetUserContactReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) case apiSetUserGroupReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) case apiSetUserAutoAcceptMemberContacts(userId: Int64, enable: Bool) + case apiSetUserAutoAcceptGroupInvitations(userId: Int64, enable: Bool) case apiHideUser(userId: Int64, viewPwd: String) case apiUnhideUser(userId: Int64, viewPwd: String) case apiMuteUser(userId: Int64) @@ -215,6 +216,8 @@ enum ChatCommand: ChatCmdProtocol { return "/_set receipts groups \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" case let .apiSetUserAutoAcceptMemberContacts(userId, enable): return "/_set accept member contacts \(userId) \(onOff(enable))" + case let .apiSetUserAutoAcceptGroupInvitations(userId, enable): + return "/_set accept group invitations \(userId) \(onOff(enable))" case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))" case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))" case let .apiMuteUser(userId): return "/_mute user \(userId)" @@ -430,6 +433,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiSetUserContactReceipts: return "apiSetUserContactReceipts" case .apiSetUserGroupReceipts: return "apiSetUserGroupReceipts" case .apiSetUserAutoAcceptMemberContacts: return "apiSetUserAutoAcceptMemberContacts" + case .apiSetUserAutoAcceptGroupInvitations: return "apiSetUserAutoAcceptGroupInvitations" case .apiHideUser: return "apiHideUser" case .apiUnhideUser: return "apiUnhideUser" case .apiMuteUser: return "apiMuteUser" diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7a934fc746..74f97134d7 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -302,6 +302,10 @@ func apiSetUserAutoAcceptMemberContacts(_ userId: Int64, enable: Bool) async thr try await sendCommandOkResp(.apiSetUserAutoAcceptMemberContacts(userId: userId, enable: enable)) } +func apiSetUserAutoAcceptGroupInvitations(_ userId: Int64, enable: Bool) async throws { + try await sendCommandOkResp(.apiSetUserAutoAcceptGroupInvitations(userId: userId, enable: enable)) +} + func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User { try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd)) } diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 6ea24ec91c..4495fd8cc2 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -42,7 +42,10 @@ private struct FeatureView { let view: () -> any View } -private let isInUS = SKStorefront().countryCode == "USA" +let isInUS = { + let code = SKStorefront().countryCode + return code == "USA" || code == "" +}() private let versionDescriptions: [VersionDescription] = [ VersionDescription( @@ -668,12 +671,12 @@ private let versionDescriptions: [VersionDescription] = [ ] ), VersionDescription( - version: "v7.0", + version: isInUS ? "v7.0.1" : "v7.0", post: nil, features: (isInUS ? [ .view(FeatureView( icon: nil, - title: "Invest in SimpleX Chat", + title: "You can now invest in SimpleX Chat", view: { InvestInSimpleXChat() } )) ] : []) + [ @@ -772,38 +775,130 @@ fileprivate struct CreateUpdateAddressShortLink: View { } fileprivate struct InvestInSimpleXChat: View { - @Environment(\.colorScheme) var colorScheme @EnvironmentObject var theme: AppTheme + @State private var showGetStakeSheet = false var body: some View { - HStack(alignment: .top, spacing: 8) { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .center, spacing: 4) { - Image(systemName: "dollarsign.circle") - .symbolRenderingMode(.monochrome) - .foregroundColor(theme.colors.secondary) - .frame(minWidth: 30, alignment: .center) - Text(verbatim: "Invest in SimpleX Chat").font(.title3).bold() - } - Text(verbatim: "Equity crowdfunding launched!") - .multilineTextAlignment(.leading) - .lineLimit(2) - if let url = URL("https://wefunder.com/simplexchat") { - ExternalLink(destination: url) { - HStack { - Text(verbatim: "Learn more on Wefunder") - Image(systemName: "arrow.up.right.circle") - } - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - Image(colorScheme == .light ? "own-stake" : "own-stake-light") + VStack(alignment: .leading, spacing: 4) { + Text("You can now invest in SimpleX Chat! 🚀").font(.title3).bold() + (Text("Crowdfunding on Wefunder.") + Text(verbatim: " ") + Text("Learn more").foregroundColor(theme.colors.primary)) + .multilineTextAlignment(.leading) + .onTapGesture { showGetStakeSheet = true } + #if SIMPLEX_ASSETS + Image("crowdfunding_1") .resizable() - .scaledToFill() - .frame(width: UIScreen.main.bounds.width / 5) + .scaledToFit() + .cornerRadius(12) + .padding(.vertical, 4) + .onTapGesture { showGetStakeSheet = true } + #endif } .frame(maxWidth: .infinity, alignment: .leading) + .sheet(isPresented: $showGetStakeSheet) { + GetStakeView(fromSettings: false) + } + } +} + +fileprivate let getStakeSlides: [(image: String, heading: String, info: String?, text: String)] = [ + ( + "crowdfunding_1", + "The first and the only messaging network without any user IDs", + nil, + "By investing, you can benefit from the company growth, and help us build the future of private and secure communications." + ), + ( + "crowdfunding_2", + "480,000+ users joined on their own", + nil, + "SimpleX users have been more than doubling every year without any paid marketing, and donated over $650,000." + ), + ( + "crowdfunding_3", + "Developers already bet on SimpleX success", + "Independent developers created moderation and AI bots, Telegram bridges, and a public server registry.", + "Every service developers build on SimpleX Network may increase its value, and bring new users to SimpleX Chat." + ), + ( + "crowdfunding_4", + "Revenue plan: free for users, channels & businesses pay", + "SimpleX Chat plans to earn from the infrastructure and services that creators, businesses and large communities need as they grow.", + "Read about how we plan to make SimpleX Chat and network profitable, and about all the investment terms on Wefunder." + ), +] + +private let wefunderURL = URL(string: "https://wefunder.com/simplex.chat?utm_source=app")! + +private let simplexCrowdfundingURL = URL(string: "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im")! + +struct GetStakeView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var chatModel: ChatModel + var fromSettings: Bool + + var body: some View { + ZoomablePageView { + VStack(alignment: .leading, spacing: 18) { + Text(verbatim: "Get a stake in\nSimpleX Chat") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + .if(!fromSettings) { $0.padding(.top) } + if fromSettings { + slideImage(getStakeSlides[0]) + } + (Text(verbatim: getStakeSlides[0].text) + Text(verbatim: " Learn more and invest on Wefunder.").bold().foregroundColor(.accentColor)) + .multilineTextAlignment(.leading) + .onTapGesture { + UIApplication.shared.open(wefunderURL) + } + .padding(.bottom) + ForEach(getStakeSlides[1...3], id: \.image) { slide in + VStack(alignment: .leading) { + slideImage(slide) + Text(slide.text) + } + .padding(.bottom) + } + + Button { + UIApplication.shared.open(wefunderURL) + } label: { + Text(verbatim: "Learn more on Wefunder") + } + .buttonStyle(OnboardingButtonStyle()) + + Button { + dismiss() + DispatchQueue.main.async { + ChatModel.shared.appOpenUrl = simplexCrowdfundingURL + } + } label: { + Text(verbatim: "or ask SimpleX team") + .font(.callout) + } + .disabled(chatModel.chatRunning != true) + .frame(maxWidth: .infinity) + } + .padding() + } + .ignoresSafeArea(edges: .bottom) + .modifier(ThemedBackground(grouped: true)) + } + + @ViewBuilder + func slideImage(_ slide: (image: String, heading: String, info: String?, text: String?)) -> some View { + #if SIMPLEX_ASSETS + Image(slide.image) + .resizable() + .scaledToFit() + .cornerRadius(12) + #else + Text(slide.heading).font(.title3).bold() + if let info = slide.info { + Text(info) + } + #endif } } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index d891efcd90..bf3dbac440 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -37,6 +37,8 @@ struct PrivacySettings: View { @State private var groupReceiptsDialogue = false @State private var autoAcceptMemberContacts = false @State private var autoAcceptMemberContactsReset = false + @State private var autoAcceptGroupInvitations = false + @State private var autoAcceptGroupInvitationsReset = false @State private var alert: PrivacySettingsViewAlert? enum PrivacySettingsViewAlert: Identifiable { @@ -117,14 +119,17 @@ struct PrivacySettings: View { } Section { - settingsRow("checkmark", color: theme.colors.secondary) { - Toggle("Auto-accept", isOn: $autoAcceptMemberContacts) + settingsRow("person", color: theme.colors.secondary) { + Toggle("Contact requests in groups", isOn: $autoAcceptMemberContacts) + } + settingsRow("person.2", color: theme.colors.secondary) { + Toggle("Group invitations", isOn: $autoAcceptGroupInvitations) } } header: { - Text("Contact requests from groups") + Text("Auto-accept") .foregroundColor(theme.colors.secondary) } footer: { - Text("This setting is for your current profile **\(m.currentUser?.displayName ?? "")**.") + Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.") .foregroundColor(theme.colors.secondary) } @@ -139,7 +144,14 @@ struct PrivacySettings: View { if autoAcceptMemberContactsReset { autoAcceptMemberContactsReset = false } else { - setAutoAcceptGrpDirectInvs(autoAcceptMemberContacts) + setAutoAcceptMemberContacts(autoAcceptMemberContacts) + } + } + .onChange(of: autoAcceptGroupInvitations) { _ in + if autoAcceptGroupInvitationsReset { + autoAcceptGroupInvitationsReset = false + } else { + setAutoAcceptGroupInvitations(autoAcceptGroupInvitations) } } .onAppear { @@ -148,6 +160,10 @@ struct PrivacySettings: View { autoAcceptMemberContactsReset = true autoAcceptMemberContacts = u.autoAcceptMemberContacts } + if autoAcceptGroupInvitations != u.autoAcceptGroupInvitations { + autoAcceptGroupInvitationsReset = true + autoAcceptGroupInvitations = u.autoAcceptGroupInvitations + } } } .alert(item: $alert) { alert in @@ -426,7 +442,7 @@ struct PrivacySettings: View { } } - private func setAutoAcceptGrpDirectInvs(_ enable: Bool) { + private func setAutoAcceptMemberContacts(_ enable: Bool) { Task { do { if let currentUser = m.currentUser { @@ -443,6 +459,23 @@ struct PrivacySettings: View { } } + private func setAutoAcceptGroupInvitations(_ enable: Bool) { + Task { + do { + if let currentUser = m.currentUser { + try await apiSetUserAutoAcceptGroupInvitations(currentUser.userId, enable: enable) + await MainActor.run { + var updatedUser = currentUser + updatedUser.autoAcceptGroupInvitations = enable + m.updateUser(updatedUser) + } + } + } catch let error { + alert = .error(title: "Error setting auto-accept", error: "Error: \(responseError(error))") + } + } + } + private func simplexLockRow(_ value: LocalizedStringKey) -> some View { HStack { Text("SimpleX Lock") diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 4bd5f7db1b..ae317cd864 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -380,6 +380,17 @@ struct SettingsView: View { Text(verbatim: "v\(appVersion ?? "?")") } } + + if isInUS { + Section(header: Text("You can now invest in SimpleX Chat").foregroundColor(theme.colors.secondary)) { + NavigationLink { + GetStakeView(fromSettings: true) + .navigationBarTitle("", displayMode: .inline) + } label: { + settingsRow("dollarsign.circle", color: theme.colors.secondary) { Text("Crowdfunding on Wefunder") } + } + } + } } .navigationTitle("Your settings") .modifier(ThemedBackground(grouped: true)) diff --git a/apps/ios/Shared/Views/ZoomableScrollView.swift b/apps/ios/Shared/Views/ZoomableScrollView.swift index 83528b593a..87eb645822 100644 --- a/apps/ios/Shared/Views/ZoomableScrollView.swift +++ b/apps/ios/Shared/Views/ZoomableScrollView.swift @@ -58,3 +58,54 @@ struct ZoomableScrollView: UIViewRepresentable { } } } + +struct ZoomablePageView: UIViewRepresentable { + private var content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.delegate = context.coordinator + scrollView.maximumZoomScale = 5 + scrollView.minimumZoomScale = 1 + scrollView.bouncesZoom = true + scrollView.backgroundColor = .clear + + let hostedView = context.coordinator.hostingController.view! + hostedView.backgroundColor = .clear + hostedView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(hostedView) + NSLayoutConstraint.activate([ + hostedView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + hostedView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + hostedView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + hostedView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + hostedView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor) + ]) + + return scrollView + } + + func makeCoordinator() -> Coordinator { + Coordinator(hostingController: UIHostingController(rootView: self.content)) + } + + func updateUIView(_ uiView: UIScrollView, context: Context) { + context.coordinator.hostingController.rootView = self.content + } + + class Coordinator: NSObject, UIScrollViewDelegate { + var hostingController: UIHostingController + + init(hostingController: UIHostingController) { + self.hostingController = hostingController + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + hostingController.view + } + } +} diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 0b7e24f040..ff99155cb9 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -2,7 +2,7 @@
- +
@@ -2429,8 +2429,8 @@ This is your own one-time link! Настройки за контакт No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2603,6 +2603,14 @@ This is your own one-time link! Линкът се създава… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Текущ kод за достъп @@ -4500,6 +4508,10 @@ Error: %2$@ Груповата покана вече е невалидна, премахната е от подателя. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Групов линк @@ -8989,10 +9001,6 @@ alert subtitle Тази настройка се прилага за съобщения в текущия ви профил **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. No comment provided by engineer. @@ -9938,6 +9946,14 @@ Repeat join request? Вече можете да изпращате съобщения до %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. No comment provided by engineer. @@ -11437,7 +11453,7 @@ last received msg: %2$@
- +
@@ -11474,7 +11490,7 @@ last received msg: %2$@
- +
@@ -11489,7 +11505,7 @@ last received msg: %2$@
- +
@@ -11511,7 +11527,7 @@ last received msg: %2$@
- +
@@ -11538,7 +11554,7 @@ last received msg: %2$@
- +
@@ -11557,7 +11573,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/contents.json b/apps/ios/SimpleX Localizations/bg.xcloc/contents.json index 21627f8e60..93b8194a0d 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/bg.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "bg", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 0549fdc20b..9f11be073f 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -2,7 +2,7 @@
- +
@@ -2333,8 +2333,8 @@ Toto je váš vlastní jednorázový odkaz! Předvolby kontaktů No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2499,6 +2499,14 @@ Toto je váš vlastní jednorázový odkaz! Creating link… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Aktuální heslo @@ -4352,6 +4360,10 @@ Error: %2$@ Skupinová pozvánka již není platná, byla odstraněna odesílatelem. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Odkaz na skupinu @@ -8748,10 +8760,6 @@ alert subtitle Toto nastavení platí pro zprávy ve vašem aktuálním profilu chatu **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. No comment provided by engineer. @@ -9655,6 +9663,14 @@ Repeat join request? Nyní můžete posílat zprávy %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. No comment provided by engineer. @@ -11124,7 +11140,7 @@ last received msg: %2$@
- +
@@ -11160,7 +11176,7 @@ last received msg: %2$@
- +
@@ -11175,7 +11191,7 @@ last received msg: %2$@
- +
@@ -11197,7 +11213,7 @@ last received msg: %2$@
- +
@@ -11224,7 +11240,7 @@ last received msg: %2$@
- +
@@ -11243,7 +11259,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/contents.json b/apps/ios/SimpleX Localizations/cs.xcloc/contents.json index 804a5e0951..61a2dc8bb8 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/cs.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "cs", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 6fd8232e93..a63c97fb8b 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -2,7 +2,7 @@
- +
@@ -2525,8 +2525,8 @@ Das ist Ihr eigener Einmal-Link! Kontakt-Präferenzen No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups KONTAKTANFRAGEN VON GRUPPEN No comment provided by engineer. @@ -2715,6 +2715,14 @@ Das ist Ihr eigener Einmal-Link! Link wird erstellt… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Aktueller Zugangscode @@ -4770,6 +4778,10 @@ Fehler: %2$@ Die Gruppeneinladung ist nicht mehr gültig, da sie vom Absender entfernt wurde. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Gruppen-Link @@ -9734,11 +9746,6 @@ alert subtitle Diese Einstellung gilt für Nachrichten in Ihrem aktuellen Chat-Profil **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - Diese Einstellung gilt für Ihr aktuelles Profil **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Die Zeit bis zum Verschwinden wird nur für neue Kontakte eingestellt. @@ -10769,6 +10776,14 @@ Verbindungsanfrage wiederholen? Sie können nun Nachrichten an %@ versenden notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Sie können aus den archivierten Kontakten heraus Nachrichten an %@ versenden. @@ -12374,7 +12389,7 @@ Zuletzt empfangene Nachricht: %2$@
- +
@@ -12411,7 +12426,7 @@ Zuletzt empfangene Nachricht: %2$@
- +
@@ -12426,7 +12441,7 @@ Zuletzt empfangene Nachricht: %2$@
- +
@@ -12448,7 +12463,7 @@ Zuletzt empfangene Nachricht: %2$@
- +
@@ -12480,7 +12495,7 @@ Zuletzt empfangene Nachricht: %2$@
- +
@@ -12502,7 +12517,7 @@ Zuletzt empfangene Nachricht: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/de.xcloc/contents.json b/apps/ios/SimpleX Localizations/de.xcloc/contents.json index 8a5b0f6b96..e49b3da67c 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/de.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "de", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 97a8d4879c..88f9cb0f74 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -2,7 +2,7 @@
- +
@@ -2525,9 +2525,9 @@ This is your own one-time link! Contact preferences No comment provided by engineer. - - Contact requests from groups - Contact requests from groups + + Contact requests in groups + Contact requests in groups No comment provided by engineer. @@ -2715,6 +2715,16 @@ This is your own one-time link! Creating link… No comment provided by engineer. + + Crowdfunding on Wefunder + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Current Passcode @@ -4770,6 +4780,11 @@ Error: %2$@ Group invitation is no longer valid, it was removed by sender. No comment provided by engineer. + + Group invitations + Group invitations + No comment provided by engineer. + Group link Group link @@ -9734,11 +9749,6 @@ alert subtitle This setting applies to messages in your current chat profile **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Time to disappear is set only for new contacts. @@ -10769,6 +10779,16 @@ Repeat join request? You can now chat with %@ notification body + + You can now invest in SimpleX Chat + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. You can send messages to %@ from Archived contacts. @@ -12374,7 +12394,7 @@ last received msg: %2$@
- +
@@ -12411,7 +12431,7 @@ last received msg: %2$@
- +
@@ -12428,7 +12448,7 @@ last received msg: %2$@
- +
@@ -12450,7 +12470,7 @@ last received msg: %2$@
- +
@@ -12482,7 +12502,7 @@ last received msg: %2$@
- +
@@ -12504,7 +12524,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/en.xcloc/contents.json b/apps/ios/SimpleX Localizations/en.xcloc/contents.json index 7b50cab8e7..5426843b95 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/en.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "en", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 6d140c7f78..8f613b0a8b 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -2,7 +2,7 @@
- +
@@ -2525,8 +2525,8 @@ This is your own one-time link! Preferencias de contacto No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Solicitudes de contacto en grupo No comment provided by engineer. @@ -2715,6 +2715,14 @@ This is your own one-time link! Creando enlace… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Código de Acceso @@ -4770,6 +4778,10 @@ Error: %2$@ La invitación al grupo ya no es válida, ha sido eliminada por el remitente. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Enlace de grupo @@ -9734,11 +9746,6 @@ alert subtitle Esta configuración se aplica a los mensajes del perfil actual **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - Esta configuración se aplica al perfil actual **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Mensajes temporales activados sólo para los contactos nuevos. @@ -10769,6 +10776,14 @@ Repeat join request? Ya puedes chatear con %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Puedes enviar mensajes a %@ desde Contactos archivados. @@ -12374,7 +12389,7 @@ last received msg: %2$@
- +
@@ -12411,7 +12426,7 @@ last received msg: %2$@
- +
@@ -12426,7 +12441,7 @@ last received msg: %2$@
- +
@@ -12448,7 +12463,7 @@ last received msg: %2$@
- +
@@ -12480,7 +12495,7 @@ last received msg: %2$@
- +
@@ -12502,7 +12517,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/es.xcloc/contents.json b/apps/ios/SimpleX Localizations/es.xcloc/contents.json index 5a4c833adf..13dd145937 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/es.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "es", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index cd6030b2be..2721c64ffd 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -2,7 +2,7 @@
- +
@@ -2220,8 +2220,8 @@ This is your own one-time link! Kontaktin asetukset No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2386,6 +2386,14 @@ This is your own one-time link! Creating link… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Nykyinen pääsykoodi @@ -4236,6 +4244,10 @@ Error: %2$@ Ryhmäkutsu ei ole enää voimassa, lähettäjä poisti sen. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Ryhmälinkki @@ -8623,10 +8635,6 @@ alert subtitle Tämä asetus koskee nykyisen keskusteluprofiilisi viestejä *%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. No comment provided by engineer. @@ -9529,6 +9537,14 @@ Repeat join request? Voit nyt lähettää viestejä %@:lle notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. No comment provided by engineer. @@ -10996,7 +11012,7 @@ last received msg: %2$@
- +
@@ -11032,7 +11048,7 @@ last received msg: %2$@
- +
@@ -11047,7 +11063,7 @@ last received msg: %2$@
- +
@@ -11069,7 +11085,7 @@ last received msg: %2$@
- +
@@ -11096,7 +11112,7 @@ last received msg: %2$@
- +
@@ -11115,7 +11131,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/contents.json b/apps/ios/SimpleX Localizations/fi.xcloc/contents.json index f61becaece..c46a7251f5 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/fi.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "fi", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index cf8eafaac6..ad7625d0f6 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -2,7 +2,7 @@
- +
@@ -2503,8 +2503,8 @@ Il s'agit de votre propre lien unique ! Préférences de contact No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Demandes de contact des groupes No comment provided by engineer. @@ -2691,6 +2691,14 @@ Il s'agit de votre propre lien unique ! Création d'un lien… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Code d'accès actuel @@ -4733,6 +4741,10 @@ Erreur : %2$@ L'invitation du groupe n'est plus valide, elle a été supprimé par l'expéditeur. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Lien du groupe @@ -9609,10 +9621,6 @@ alert subtitle Ce paramètre s'applique aux messages de votre profil de chat actuel **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Le délai de disparition est défini seulement pour les nouveaux contacts. @@ -10608,6 +10616,14 @@ Répéter la demande d'adhésion ? Vous pouvez maintenant envoyer des messages à %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Vous pouvez envoyer des messages à %@ à partir des contacts archivés. @@ -12181,7 +12197,7 @@ dernier message reçu : %2$@
- +
@@ -12218,7 +12234,7 @@ dernier message reçu : %2$@
- +
@@ -12233,7 +12249,7 @@ dernier message reçu : %2$@
- +
@@ -12255,7 +12271,7 @@ dernier message reçu : %2$@
- +
@@ -12287,7 +12303,7 @@ dernier message reçu : %2$@
- +
@@ -12309,7 +12325,7 @@ dernier message reçu : %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/contents.json b/apps/ios/SimpleX Localizations/fr.xcloc/contents.json index f41d7b4888..8529eeed82 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/fr.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "fr", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index b3facbe03e..51eb11f548 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -2,7 +2,7 @@
- +
@@ -2525,8 +2525,8 @@ Ez a saját egyszer használható meghívója! Partnerbeállítások No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Partneri kapcsolatkérések a csoportokból No comment provided by engineer. @@ -2715,6 +2715,14 @@ Ez a saját egyszer használható meghívója! Hivatkozás létrehozása… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Jelenlegi jelkód @@ -4770,6 +4778,10 @@ Hiba: %2$@ A csoportmeghívó már nem érvényes, a küldője eltávolította. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Csoporthivatkozás @@ -9734,11 +9746,6 @@ alert subtitle Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegési profiljában lévő üzenetekre vonatkozik. No comment provided by engineer. - - This setting is for your current profile **%@**. - Ez a beállítás csak a jelenlegi **%@** nevű csevegési profiljára vonatkozik. - No comment provided by engineer. - Time to disappear is set only for new contacts. Az üzeneteltűnési idő csak az új partnerekre vonatkozik. @@ -10769,6 +10776,14 @@ Megismétli a csatlakozási kérést? Mostantól küldhet üzeneteket %@ számára notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Az „Archivált partnerekből” továbbra is küldhet üzeneteket neki: %@. @@ -12374,7 +12389,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -12411,7 +12426,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -12426,7 +12441,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -12448,7 +12463,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -12480,7 +12495,7 @@ utoljára fogadott üzenet: %2$@
- +
@@ -12502,7 +12517,7 @@ utoljára fogadott üzenet: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/contents.json b/apps/ios/SimpleX Localizations/hu.xcloc/contents.json index 31997434c3..23d2f9c992 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/hu.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "hu", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 5381b8cbb5..bc7be9498a 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -2,7 +2,7 @@
- +
@@ -2525,8 +2525,8 @@ Questo è il tuo link una tantum! Preferenze del contatto No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Richieste di contatto dai gruppi No comment provided by engineer. @@ -2715,6 +2715,14 @@ Questo è il tuo link una tantum! Creazione link… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Codice di accesso attuale @@ -4770,6 +4778,10 @@ Errore: %2$@ L'invito al gruppo non è più valido, è stato rimosso dal mittente. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Link del gruppo @@ -9734,11 +9746,6 @@ alert subtitle Questa impostazione si applica ai messaggi del profilo di chat attuale **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - Questa impostazione è per il tuo profilo attuale **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Il tempo di scomparsa è impostato solo per i contatti nuovi. @@ -10769,6 +10776,14 @@ Ripetere la richiesta di ingresso? Ora puoi inviare messaggi a %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Puoi inviare messaggi a %@ dai contatti archiviati. @@ -12374,7 +12389,7 @@ ultimo msg ricevuto: %2$@
- +
@@ -12411,7 +12426,7 @@ ultimo msg ricevuto: %2$@
- +
@@ -12426,7 +12441,7 @@ ultimo msg ricevuto: %2$@
- +
@@ -12448,7 +12463,7 @@ ultimo msg ricevuto: %2$@
- +
@@ -12480,7 +12495,7 @@ ultimo msg ricevuto: %2$@
- +
@@ -12502,7 +12517,7 @@ ultimo msg ricevuto: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/it.xcloc/contents.json b/apps/ios/SimpleX Localizations/it.xcloc/contents.json index 36fd18d76d..5862575e41 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/it.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "it", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index c8b2285649..5f43fbade6 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -2,7 +2,7 @@
- +
@@ -2325,8 +2325,8 @@ This is your own one-time link! 連絡先の設定 No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2491,6 +2491,14 @@ This is your own one-time link! Creating link… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode 現在のパスコード @@ -4353,6 +4361,10 @@ Error: %2$@ グループ招待が無効となり、送信元によって取り消されました。 No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link グループのリンク @@ -8736,10 +8748,6 @@ alert subtitle この設定は現在のチャットプロフィール **%@** のメッセージに適用されます。 No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. No comment provided by engineer. @@ -9643,6 +9651,14 @@ Repeat join request? %@ にメッセージを送信できるようになりました notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. No comment provided by engineer. @@ -11111,7 +11127,7 @@ last received msg: %2$@
- +
@@ -11147,7 +11163,7 @@ last received msg: %2$@
- +
@@ -11162,7 +11178,7 @@ last received msg: %2$@
- +
@@ -11184,7 +11200,7 @@ last received msg: %2$@
- +
@@ -11211,7 +11227,7 @@ last received msg: %2$@
- +
@@ -11230,7 +11246,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/contents.json b/apps/ios/SimpleX Localizations/ja.xcloc/contents.json index f52b6f4654..095625fb0f 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/ja.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "ja", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 11f81cf90b..7cbb9dbc6a 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -2,7 +2,7 @@
- +
@@ -2422,8 +2422,8 @@ Dit is uw eigen eenmalige link! Contact voorkeuren No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2603,6 +2603,14 @@ Dit is uw eigen eenmalige link! Link maken… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Huidige toegangscode @@ -4605,6 +4613,10 @@ Fout: %2$@ Groep uitnodiging is niet meer geldig, deze is verwijderd door de afzender. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Groep link @@ -9353,10 +9365,6 @@ alert subtitle Deze instelling is van toepassing op berichten in je huidige chatprofiel **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. No comment provided by engineer. @@ -10349,6 +10357,14 @@ Deelnameverzoek herhalen? Je kunt nu berichten sturen naar %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. U kunt berichten naar %@ sturen vanuit gearchiveerde contacten. @@ -11903,7 +11919,7 @@ laatst ontvangen bericht: %2$@
- +
@@ -11940,7 +11956,7 @@ laatst ontvangen bericht: %2$@
- +
@@ -11955,7 +11971,7 @@ laatst ontvangen bericht: %2$@
- +
@@ -11977,7 +11993,7 @@ laatst ontvangen bericht: %2$@
- +
@@ -12009,7 +12025,7 @@ laatst ontvangen bericht: %2$@
- +
@@ -12031,7 +12047,7 @@ laatst ontvangen bericht: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/contents.json b/apps/ios/SimpleX Localizations/nl.xcloc/contents.json index 36c6d27526..d2b453a7c5 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/nl.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "nl", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 19401459d5..e483c8686c 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -2,7 +2,7 @@
- +
@@ -2439,8 +2439,8 @@ To jest twój jednorazowy link! Preferencje kontaktu No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Prośby o kontakt od grup No comment provided by engineer. @@ -2622,6 +2622,14 @@ To jest twój jednorazowy link! Tworzenie linku… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Aktualny Pin @@ -4641,6 +4649,10 @@ Błąd: %2$@ Zaproszenie do grupy jest już nieważne, zostało usunięte przez nadawcę. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Link do grupy @@ -9449,11 +9461,6 @@ alert subtitle To ustawienie dotyczy wiadomości Twojego bieżącego profilu czatu **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - To ustawienie jest dla Twojego obecnego profilu **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Czas zniknięcia jest ustawiony tylko dla nowych kontaktów. @@ -10461,6 +10468,14 @@ Powtórzyć prośbę dołączenia? Możesz teraz wysyłać wiadomości do %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Możesz wysyłać wiadomości do %@ ze zarchiwizowanych kontaktów. @@ -12029,7 +12044,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
@@ -12066,7 +12081,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
@@ -12081,7 +12096,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
@@ -12103,7 +12118,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
@@ -12135,7 +12150,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
@@ -12157,7 +12172,7 @@ ostatnia otrzymana wiadomość: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/contents.json b/apps/ios/SimpleX Localizations/pl.xcloc/contents.json index 2f5237052c..bf3e16596b 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/pl.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "pl", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 7254d80c9c..1942b9c0aa 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -2,7 +2,7 @@
- +
@@ -2525,8 +2525,8 @@ This is your own one-time link! Предпочтения контакта No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Запросы на соединение из групп No comment provided by engineer. @@ -2715,6 +2715,14 @@ This is your own one-time link! Создаётся ссылка… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Текущий Код @@ -4770,6 +4778,10 @@ Error: %2$@ Приглашение в группу больше не действительно, оно было удалено отправителем. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Ссылка группы @@ -9733,11 +9745,6 @@ alert subtitle Эта настройка применяется к сообщениям в Вашем текущем профиле чата **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - Эта настройка применяется к Вашему текущему профилю чата **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Время удаления устанавливается только для новых контактов. @@ -10768,6 +10775,14 @@ Repeat join request? Вы теперь можете общаться с %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Вы можете отправлять сообщения %@ из Архивированных контактов. @@ -12373,7 +12388,7 @@ last received msg: %2$@
- +
@@ -12410,7 +12425,7 @@ last received msg: %2$@
- +
@@ -12425,7 +12440,7 @@ last received msg: %2$@
- +
@@ -12447,7 +12462,7 @@ last received msg: %2$@
- +
@@ -12479,7 +12494,7 @@ last received msg: %2$@
- +
@@ -12501,7 +12516,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/contents.json b/apps/ios/SimpleX Localizations/ru.xcloc/contents.json index 9907ddc7fc..996db14639 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/ru.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "ru", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 20dec9b68d..28332012c4 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -2,7 +2,7 @@
- +
@@ -2211,8 +2211,8 @@ This is your own one-time link! การกําหนดลักษณะการติดต่อ No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2375,6 +2375,14 @@ This is your own one-time link! Creating link… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode รหัสผ่านปัจจุบัน @@ -4221,6 +4229,10 @@ Error: %2$@ คำเชิญเข้าร่วมกลุ่มใช้ไม่ถูกต้องอีกต่อไป คำเชิญถูกลบโดยผู้ส่ง No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link ลิงค์กลุ่ม @@ -8593,10 +8605,6 @@ alert subtitle การตั้งค่านี้ใช้กับข้อความในโปรไฟล์แชทปัจจุบันของคุณ **%@** No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. No comment provided by engineer. @@ -9497,6 +9505,14 @@ Repeat join request? ตอนนี้คุณสามารถส่งข้อความถึง %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. No comment provided by engineer. @@ -10961,7 +10977,7 @@ last received msg: %2$@
- +
@@ -10997,7 +11013,7 @@ last received msg: %2$@
- +
@@ -11012,7 +11028,7 @@ last received msg: %2$@
- +
@@ -11034,7 +11050,7 @@ last received msg: %2$@
- +
@@ -11061,7 +11077,7 @@ last received msg: %2$@
- +
@@ -11080,7 +11096,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/th.xcloc/contents.json b/apps/ios/SimpleX Localizations/th.xcloc/contents.json index a18ced87af..f01660f74f 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/th.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "th", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 00385e5312..62df79c62b 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -2,7 +2,7 @@
- +
@@ -2450,8 +2450,8 @@ Bu senin kendi tek kullanımlık bağlantın! Kişi tercihleri No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Gruplardan gelen iletişim talepleri No comment provided by engineer. @@ -2633,6 +2633,14 @@ Bu senin kendi tek kullanımlık bağlantın! Link oluşturuluyor… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Şu anki şifre @@ -4644,6 +4652,10 @@ Hata: %2$@ Grup davet artık geçerli değil, gönderici tarafından silindi. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Grup bağlantısı @@ -9436,11 +9448,6 @@ alert subtitle Bu ayar, geçerli sohbet profiliniz **%@** deki mesajlara uygulanır. No comment provided by engineer. - - This setting is for your current profile **%@**. - Bu ayar, mevcut profiliniz içindir. - No comment provided by engineer. - Time to disappear is set only for new contacts. Kaybolma süresi yalnızca yeni kişiler için ayarlanır. @@ -10444,6 +10451,14 @@ Katılma isteği tekrarlansın mı? Artık %@ adresine mesaj gönderebilirsin notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Arşivlenen kişilerden %@'ya mesaj gönderebilirsiniz. @@ -12008,7 +12023,7 @@ son alınan msj: %2$@
- +
@@ -12045,7 +12060,7 @@ son alınan msj: %2$@
- +
@@ -12060,7 +12075,7 @@ son alınan msj: %2$@
- +
@@ -12082,7 +12097,7 @@ son alınan msj: %2$@
- +
@@ -12114,7 +12129,7 @@ son alınan msj: %2$@
- +
@@ -12136,7 +12151,7 @@ son alınan msj: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/contents.json b/apps/ios/SimpleX Localizations/tr.xcloc/contents.json index 1aa8013358..1e9b3cdc53 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/tr.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "tr", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index fe69dea6ab..f917609da7 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -2,7 +2,7 @@
- +
@@ -2472,8 +2472,8 @@ This is your own one-time link! Налаштування контактів No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2654,6 +2654,14 @@ This is your own one-time link! Створення посилання… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Поточний пароль @@ -4662,6 +4670,10 @@ Error: %2$@ Групове запрошення більше не дійсне, воно було видалено відправником. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Посилання на групу @@ -9445,10 +9457,6 @@ alert subtitle Це налаштування застосовується до повідомлень у вашому поточному профілі чату **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Час зникнення встановлюється тільки для нових контактів. @@ -10451,6 +10459,14 @@ Repeat join request? Тепер ви можете надсилати повідомлення на адресу %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Ви можете надсилати повідомлення на %@ з архівних контактів. @@ -12014,7 +12030,7 @@ last received msg: %2$@
- +
@@ -12051,7 +12067,7 @@ last received msg: %2$@
- +
@@ -12066,7 +12082,7 @@ last received msg: %2$@
- +
@@ -12088,7 +12104,7 @@ last received msg: %2$@
- +
@@ -12120,7 +12136,7 @@ last received msg: %2$@
- +
@@ -12142,7 +12158,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/contents.json b/apps/ios/SimpleX Localizations/uk.xcloc/contents.json index c6053c573c..507dcc4ad1 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/uk.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "uk", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 1f6067fe53..67b08d7343 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -2,7 +2,7 @@
- +
@@ -2521,8 +2521,8 @@ This is your own one-time link! 联系人偏好设置 No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups 来自群的联络请求 No comment provided by engineer. @@ -2710,6 +2710,14 @@ This is your own one-time link! 创建链接中… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode 当前密码 @@ -4755,6 +4763,10 @@ Error: %2$@ 群组邀请不再有效,已被发件人删除。 No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link 群组链接 @@ -9695,11 +9707,6 @@ alert subtitle 此设置适用于您当前聊天资料 **%@** 中的消息。 No comment provided by engineer. - - This setting is for your current profile **%@**. - 此设置用于当前个人资料 **%@**。 - No comment provided by engineer. - Time to disappear is set only for new contacts. 只为新联系人设置了消失时间。 @@ -10725,6 +10732,14 @@ Repeat join request? 您现在可以给 %@ 发送消息 notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. 您可以从存档的联系人向%@发送消息。 @@ -12327,7 +12342,7 @@ last received msg: %2$@
- +
@@ -12364,7 +12379,7 @@ last received msg: %2$@
- +
@@ -12379,7 +12394,7 @@ last received msg: %2$@
- +
@@ -12401,7 +12416,7 @@ last received msg: %2$@
- +
@@ -12433,7 +12448,7 @@ last received msg: %2$@
- +
@@ -12455,7 +12470,7 @@ last received msg: %2$@
- +
diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/contents.json b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/contents.json index f8b8f54d3a..f2a57b2dd9 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/contents.json +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/contents.json @@ -3,10 +3,10 @@ "project" : "SimpleX.xcodeproj", "targetLocale" : "zh-Hans", "toolInfo" : { - "toolBuildNumber" : "17F113", + "toolBuildNumber" : "17F42", "toolID" : "com.apple.dt.xcode", "toolName" : "Xcode", - "toolVersion" : "26.6" + "toolVersion" : "26.5" }, "version" : "1.0" } \ No newline at end of file diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ddc9454b86..7f9f1a4fcc 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -42,6 +42,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var sendRcptsContacts: Bool public var sendRcptsSmallGroups: Bool public var autoAcceptMemberContacts: Bool + public var autoAcceptGroupInvitations: Bool public var viewPwdHash: UserPwdHash? public var uiThemes: ThemeModeOverrides? public var userChatRelay: Bool @@ -71,6 +72,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { sendRcptsContacts: true, sendRcptsSmallGroups: false, autoAcceptMemberContacts: false, + autoAcceptGroupInvitations: false, userChatRelay: false ) } diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 41f64e5400..91ee6f3388 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -1692,7 +1692,7 @@ server test step */ "Contact preferences" = "Kontakt-Präferenzen"; /* No comment provided by engineer. */ -"Contact requests from groups" = "KONTAKTANFRAGEN VON GRUPPEN"; +"Contact requests in groups" = "KONTAKTANFRAGEN VON GRUPPEN"; /* No comment provided by engineer. */ "contact should accept…" = "Kontakt sollte annehmen…"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index a6d4e9d20c..46e86419b2 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -1692,7 +1692,7 @@ server test step */ "Contact preferences" = "Preferencias de contacto"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Solicitudes de contacto en grupo"; +"Contact requests in groups" = "Solicitudes de contacto en grupo"; /* No comment provided by engineer. */ "contact should accept…" = "el contacto debe aceptarte…"; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 0952cacb90..5bfc7dca1d 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1612,7 +1612,7 @@ server test step */ "Contact preferences" = "Préférences de contact"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Demandes de contact des groupes"; +"Contact requests in groups" = "Demandes de contact des groupes"; /* No comment provided by engineer. */ "Contact will be deleted - this cannot be undone!" = "Le contact sera supprimé - il n'est pas possible de revenir en arrière !"; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 330688163e..ea687106b4 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -1692,7 +1692,7 @@ server test step */ "Contact preferences" = "Partnerbeállítások"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Partneri kapcsolatkérések a csoportokból"; +"Contact requests in groups" = "Partneri kapcsolatkérések a csoportokból"; /* No comment provided by engineer. */ "contact should accept…" = "a partnernek el kell fogadnia…"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 3544dc1813..5871fb7615 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -1692,7 +1692,7 @@ server test step */ "Contact preferences" = "Preferenze del contatto"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Richieste di contatto dai gruppi"; +"Contact requests in groups" = "Richieste di contatto dai gruppi"; /* No comment provided by engineer. */ "contact should accept…" = "il contatto deve accettare…"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 07c407416a..c7aa746e53 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -1416,7 +1416,7 @@ server test step */ "Contact preferences" = "Preferencje kontaktu"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Prośby o kontakt od grup"; +"Contact requests in groups" = "Prośby o kontakt od grup"; /* No comment provided by engineer. */ "contact should accept…" = "kontakt powinien zaakceptować…"; diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index ce7e446211..79c28bdfb6 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -1692,7 +1692,7 @@ server test step */ "Contact preferences" = "Предпочтения контакта"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Запросы на соединение из групп"; +"Contact requests in groups" = "Запросы на соединение из групп"; /* No comment provided by engineer. */ "contact should accept…" = "контакт должен принять…"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index b0dd32c383..d2e87a9fb5 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -1454,7 +1454,7 @@ server test step */ "Contact preferences" = "Kişi tercihleri"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Gruplardan gelen iletişim talepleri"; +"Contact requests in groups" = "Gruplardan gelen iletişim talepleri"; /* No comment provided by engineer. */ "contact should accept…" = "kişi kabul etmeli…"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index e5d35b7426..e8a06c7d00 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -1680,7 +1680,7 @@ server test step */ "Contact preferences" = "联系人偏好设置"; /* No comment provided by engineer. */ -"Contact requests from groups" = "来自群的联络请求"; +"Contact requests in groups" = "来自群的联络请求"; /* No comment provided by engineer. */ "contact should accept…" = "联系人应当接受…"; diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 141d2d2665..de6834ba41 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -233,7 +233,8 @@ actual fun getFileName(uri: URI): String? { val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) cursor.moveToFirst() // Can make an exception - cursor.getString(nameIndex) + // the provider controls this value, and callers use it as a bare file name + cursor.getString(nameIndex)?.let { File(it).name } } } catch (e: Exception) { null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 340ba2c1ad..11da8b874e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1293,6 +1293,7 @@ data class User( val sendRcptsContacts: Boolean, val sendRcptsSmallGroups: Boolean, val autoAcceptMemberContacts: Boolean, + val autoAcceptGroupInvitations: Boolean, val viewPwdHash: UserPwdHash?, val uiThemes: ThemeModeOverrides? = null, val userChatRelay: Boolean, @@ -1325,6 +1326,7 @@ data class User( sendRcptsContacts = true, sendRcptsSmallGroups = false, autoAcceptMemberContacts = false, + autoAcceptGroupInvitations = false, viewPwdHash = null, uiThemes = null, userChatRelay = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 037c02b1c2..c351ee3281 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -941,6 +941,12 @@ object ChatController { throw Exception("failed to set auto-accept ${r.responseType} ${r.details}") } + suspend fun apiSetUserAutoAcceptGroupInvitations(u: User, enable: Boolean) { + val r = sendCmd(u.remoteHostId, CC.ApiSetUserAutoAcceptGroupInvitations(u.userId, enable)) + if (r.result is CR.CmdOk) return + throw Exception("failed to set auto-accept group invitations ${r.responseType} ${r.details}") + } + suspend fun apiHideUser(u: User, viewPwd: String): User = setUserPrivacy(u.remoteHostId, CC.ApiHideUser(u.userId, viewPwd)) @@ -3775,6 +3781,7 @@ sealed class CC { class ApiSetUserContactReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC() class ApiSetUserGroupReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC() class ApiSetUserAutoAcceptMemberContacts(val userId: Long, val enable: Boolean): CC() + class ApiSetUserAutoAcceptGroupInvitations(val userId: Long, val enable: Boolean): CC() class ApiHideUser(val userId: Long, val viewPwd: String): CC() class ApiUnhideUser(val userId: Long, val viewPwd: String): CC() class ApiMuteUser(val userId: Long): CC() @@ -3965,6 +3972,7 @@ sealed class CC { "/_set receipts groups $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}" } is ApiSetUserAutoAcceptMemberContacts -> "/_set accept member contacts $userId ${onOff(enable)}" + is ApiSetUserAutoAcceptGroupInvitations -> "/_set accept group invitations $userId ${onOff(enable)}" is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}" is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}" is ApiMuteUser -> "/_mute user $userId" @@ -4176,6 +4184,7 @@ sealed class CC { is ApiSetUserContactReceipts -> "apiSetUserContactReceipts" is ApiSetUserGroupReceipts -> "apiSetUserGroupReceipts" is ApiSetUserAutoAcceptMemberContacts -> "apiSetUserAutoAcceptMemberContacts" + is ApiSetUserAutoAcceptGroupInvitations -> "apiSetUserAutoAcceptGroupInvitations" is ApiHideUser -> "apiHideUser" is ApiUnhideUser -> "apiUnhideUser" is ApiMuteUser -> "apiMuteUser" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 12f13a426f..9bbfda558f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -502,14 +502,17 @@ fun ChatView( groupMembersJob = scope.launch(Dispatchers.Default) { val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second - val (_, code) = if (member.memberActive) { + val (updatedMember, code) = if (member.memberActive) { val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId) - member to memCode?.second + (memCode?.first ?: r?.first ?: member) to memCode?.second } else { - member to null + (r?.first ?: member) to null + } + if (!isActive || chatModel.chatId.value != groupInfo.id) return@launch + // members are not loaded in large groups, so only the opened member is added to the model + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember) } - setGroupMembers(chatRh, groupInfo, chatModel) - if (!isActive) return@launch if (chatsCtx.secondaryContextFilter == null) { ModalManager.end.closeModals() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 80f97d1caf..241826ac41 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -753,6 +753,11 @@ private fun saveArchiveFromURI(importedArchiveURI: URI): String? { if (inputStream != null && archiveName != null) { val archivePath = "$databaseExportDir${File.separator}$archiveName" val destFile = File(archivePath) + // resolves symlinks, so it also catches a final component linking outside the folder + if (destFile.canonicalFile.parentFile != databaseExportDir.canonicalFile) { + Log.e(TAG, "saveArchiveFromURI path outside of export folder") + return null + } Files.copy(inputStream, destFile.toPath(), StandardCopyOption.REPLACE_EXISTING) archivePath } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt index ee63846657..cf2ceaf2d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt @@ -31,7 +31,8 @@ fun AppBarTitle( val connection = if (enableAlphaChanges) handler?.connection else null LaunchedEffect(title) { if (enableAlphaChanges) { - handler?.title?.value = title + // the app bar shows a single line, so the line breaks of the large title are replaced with spaces + handler?.title?.value = title.replace("\n", " ") } else { handler?.connection?.scrollTrackingEnabled = false } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index ea95bc2045..8e54fb4a7e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -1,6 +1,10 @@ package chat.simplex.common.views.onboarding import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* @@ -8,15 +12,38 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.BuildConfigCommon @@ -35,6 +62,7 @@ import chat.simplex.common.views.usersettings.showAddShortLinkAlert import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource +import kotlin.math.absoluteValue @Composable fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Boolean = false, close: () -> Unit) { @@ -914,14 +942,15 @@ private val versionDescriptions: List = listOf( ) ), VersionDescription( - version = "v7.0", + // the trailing space differs from the previously released "v7.0", so that What's new is shown again + version = if (isInUs()) "v7.0.1" else "v7.0", post = null, features = listOf( -// VersionFeature.FeatureView( -// icon = null, -// titleId = MR.strings.v7_0_invest, -// view = { _ -> InvestInSimpleXChatView() } -// ), + VersionFeature.FeatureView( + icon = null, + titleId = MR.strings.v7_0_invest, + view = { modalManager -> InvestInSimpleXChatView(modalManager) } + ), VersionFeature.FeatureDescription( icon = MR.images.ic_alternate_email, titleId = MR.strings.v7_0_simplex_names, @@ -956,57 +985,294 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean { return v != lastVersion } -// private const val WEFUNDER_URL = "https://wefunder.com/simplexchat" -// -// @Composable -// private fun InvestInSimpleXChatView() { -// if (platform.androidIsPlayStoreBuild) { -// LaunchedEffect(Unit) { if (androidPlayStoreCountry.value == null) platform.androidLoadPlayStoreCountry() } -// if (androidPlayStoreCountry.value != "US") return -// } -// val uriHandler = LocalUriHandler.current -// Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 12.dp)) { -// Column(modifier = Modifier.weight(1f)) { -// Row( -// verticalAlignment = Alignment.CenterVertically, -// horizontalArrangement = Arrangement.spacedBy(8.dp), -// modifier = Modifier.padding(bottom = 4.dp) -// ) { -// Icon(painterResource(MR.images.ic_redeem), stringResource(MR.strings.v7_0_invest), tint = MaterialTheme.colors.secondary) -// Text( -// generalGetString(MR.strings.v7_0_invest), -// maxLines = 2, -// overflow = TextOverflow.Ellipsis, -// style = MaterialTheme.typography.h4, -// fontWeight = FontWeight.Medium, -// modifier = Modifier.padding(bottom = 6.dp) -// ) -// } -// Text(generalGetString(MR.strings.v7_0_invest_descr), fontSize = 15.sp, modifier = Modifier.padding(bottom = 4.dp)) -// Row( -// verticalAlignment = Alignment.CenterVertically, -// horizontalArrangement = Arrangement.spacedBy(8.dp), -// modifier = Modifier -// .clickable( -// interactionSource = remember { MutableInteractionSource() }, -// indication = null -// ) { -// uriHandler.openExternalLink(WEFUNDER_URL) -// } -// ) { -// Text(stringResource(MR.strings.v7_0_invest_learn_more), color = MaterialTheme.colors.primary, fontSize = 15.sp) -// Icon(painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.v7_0_invest_learn_more), tint = MaterialTheme.colors.primary) -// } -// } -// if (BuildConfigCommon.SIMPLEX_ASSETS) { -// Image( -// painterResource(if (isInDarkTheme()) MR.images.own_stake_light else MR.images.own_stake), -// contentDescription = null, -// modifier = Modifier.width(80.dp) -// ) -// } -// } -// } +private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat" + +private const val CROWDFUNDING_CONTACT_URI = "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im" + +// the center modal takes the remaining width of the window, so the image is limited to its design width +private val MAX_CROWDFUNDING_IMAGE_WIDTH = DEFAULT_MIN_CENTER_MODAL_WIDTH + +// the width of the page images shipped with the desktop app, so that they are never upscaled +private val CROWDFUNDING_PAGE_IMAGE_WIDTH = DEFAULT_MIN_CENTER_MODAL_WIDTH + +// the corner radius the images are designed with, and the same radius as a share of their design width +private val CROWDFUNDING_IMAGE_CORNER_RADIUS = 12.dp +private const val CROWDFUNDING_IMAGE_CORNER_RADIUS_RATIO = 0.03f + +private class CrowdfundingLayout( + val maxImageWidth: Dp, + val imageShape: Shape, + // the modal manager that shows the page in the center of the window, or null when nothing does + private val centerOfWindow: ModalManager? +) { + fun inCenterOfWindow(modalManager: ModalManager) = modalManager === centerOfWindow +} + +// the images are designed for the width of a phone screen, which Android always gives them. On desktop +// they are limited to their own width, and their radius is scaled with them, as they are still shown +// wider than designed: a fixed radius would not only look almost square, but would also leave the corners +// baked into the jpegs visible - they have black behind them, as jpegs have no transparency +private val crowdfundingLayout = if (appPlatform.isDesktop) + CrowdfundingLayout(CROWDFUNDING_PAGE_IMAGE_WIDTH, object : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline = + Outline.Rounded(RoundRect(size.toRect(), CornerRadius(size.width * CROWDFUNDING_IMAGE_CORNER_RADIUS_RATIO))) + }, ModalManager.center) +else + CrowdfundingLayout(Dp.Unspecified, RoundedCornerShape(CROWDFUNDING_IMAGE_CORNER_RADIUS), null) + +// Google Play policy restricts promoting investments, so Play builds only show it in the US +@Composable +fun crowdfundingAvailable(): Boolean { + if (!platform.androidIsPlayStoreBuild) return true + if (androidPlayStoreCountry.value == null) { + LaunchedEffect(Unit) { + if (androidPlayStoreCountry.value == null) platform.androidLoadPlayStoreCountry() + } + } + return isInUs() +} + +fun isInUs(): Boolean = + androidPlayStoreCountry.value == "US" + || androidPlayStoreCountry.value == "" + || androidPlayStoreCountry.value == null + +@Composable +private fun InvestInSimpleXChatView(modalManager: ModalManager) { + if (!crowdfundingAvailable()) return + val showGetStake = { modalManager.showModalCloseable(cardScreen = true) { close -> GetStakeView(fromSettings = false, inCenterOfWindow = crowdfundingLayout.inCenterOfWindow(modalManager), close = close) } } + Column(modifier = Modifier.padding(bottom = 12.dp)) { + Text( + generalGetString(MR.strings.v7_0_invest), + style = MaterialTheme.typography.h4, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 6.dp) + ) + Text( + buildAnnotatedString { + append(generalGetString(MR.strings.v7_0_invest_descr)) + append(" ") + withStyle(SpanStyle(color = MaterialTheme.colors.primary)) { + append(generalGetString(MR.strings.learn_more)) + } + }, + fontSize = 15.sp, + modifier = Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = showGetStake + ) + ) + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(MR.images.crowdfunding_1), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 8.dp) + .widthIn(max = MAX_CROWDFUNDING_IMAGE_WIDTH) + .fillMaxWidth() + .clip(crowdfundingLayout.imageShape) + .pointerHoverIcon(PointerIcon.Hand) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = showGetStake + ) + ) + } + } +} + +private class CrowdfundingSlide( + val image: ImageResource, + val heading: String, + val info: String?, + val text: String, +) + +// not localized: the page is only shown to US investors, and the text duplicates the images +private val getStakeSlides: List = listOf( + CrowdfundingSlide( + MR.images.crowdfunding_1, + "The first and the only messaging network without any user IDs", + null, + "By investing, you can benefit from the company growth, and help us build the future of private and secure communications." + ), + CrowdfundingSlide( + MR.images.crowdfunding_2, + "480,000+ users joined on their own", + null, + "SimpleX users have been more than doubling every year without any paid marketing, and donated over \$650,000." + ), + CrowdfundingSlide( + MR.images.crowdfunding_3, + "Developers already bet on SimpleX success", + "Independent developers created moderation and AI bots, Telegram bridges, and a public server registry.", + "Every service developers build on SimpleX Network may increase its value, and bring new users to SimpleX Chat." + ), + CrowdfundingSlide( + MR.images.crowdfunding_4, + "Revenue plan: free for users, channels & businesses pay", + "SimpleX Chat plans to earn from the infrastructure and services that creators, businesses and large communities need as they grow.", + "Read about how we plan to make SimpleX Chat and network profitable, and about all the investment terms on Wefunder." + ), +) + +@Composable +fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close: () -> Unit) { + val uriHandler = LocalUriHandler.current + val stopped = chatModel.chatRunning.value == false + + @Composable + fun slideImage(slide: CrowdfundingSlide) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(slide.image), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .widthIn(max = crowdfundingLayout.maxImageWidth) + .fillMaxWidth() + .clip(crowdfundingLayout.imageShape) + .fullScreenOnClick(slide.image) + ) + } else { + Text(slide.heading, style = MaterialTheme.typography.h4, fontWeight = FontWeight.Medium) + if (slide.info != null) { + Text(slide.info, Modifier.padding(top = 4.dp), lineHeight = 24.sp) + } + } + } + + ColumnWithScrollBar(Modifier.pinchZoom().padding(horizontal = DEFAULT_PADDING)) { + // in the center of the window the page is wide enough for the title to fit on one line + val title = "Get a stake in\nSimpleX Chat" + AppBarTitle(if (inCenterOfWindow) title.replace("\n", " ") else title, withPadding = false) + // What's new already shows the image of the first slide, above the link that opens this page + if (fromSettings) { + slideImage(getStakeSlides[0]) + } + Text( + buildAnnotatedString { + append(getStakeSlides[0].text) + // only the link is clickable, the rest of the paragraph is not + withLink(LinkAnnotation.Url(WEFUNDER_URL) { uriHandler.openUriCatching(WEFUNDER_URL) }) { + withStyle(SpanStyle(color = MaterialTheme.colors.primary, fontWeight = FontWeight.Bold)) { + append(" Learn more and invest on Wefunder.") + } + } + }, + Modifier.padding(top = if (fromSettings) 8.dp else 0.dp), + lineHeight = 24.sp + ) + + getStakeSlides.drop(1).forEach { slide -> + Column(Modifier.padding(top = DEFAULT_PADDING * 1.5f)) { + slideImage(slide) + Text(slide.text, Modifier.padding(top = 8.dp), lineHeight = 24.sp) + } + } + + Column( + Modifier.fillMaxWidth().padding(top = DEFAULT_PADDING * 2), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OnboardingActionButton( + if (appPlatform.isAndroid) Modifier.fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.v7_0_invest_learn_more, + onboarding = null, + onclick = { uriHandler.openUriCatching(WEFUNDER_URL) } + ) + if (!chatModel.desktopNoUserNoRemote) { + TextButtonBelowOnboardingButton( + "or ask SimpleX team", + onClick = if (stopped) null else ({ + close() + uriHandler.openVerifiedSimplexUri(CROWDFUNDING_CONTACT_URI) + }) + ) + } + } + } +} + +// there is no pinch gesture with a mouse, so on desktop a slide is opened full screen instead +@Composable +private fun Modifier.fullScreenOnClick(image: ImageResource): Modifier { + if (!appPlatform.isDesktop) return this + return pointerHoverIcon(PointerIcon.Hand).clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + ModalManager.fullscreen.showCustomModal { close -> + BackHandler(onBack = close) + Box( + Modifier + .fillMaxSize() + .background(Color.Black) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = close), + contentAlignment = Alignment.Center + ) { + Image(painterResource(image), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize()) + } + } + } +} + +private const val MAX_PAGE_ZOOM = 5f + +/** + * The slide images contain small text that is unreadable at screen width, so the page can be pinch-zoomed. + * Android only: pinch is unavailable with a mouse. + */ +@Composable +private fun Modifier.pinchZoom(): Modifier { + if (!appPlatform.isAndroid) return this + var scale by remember { mutableStateOf(1f) } + var offsetX by remember { mutableStateOf(0f) } + var offsetY by remember { mutableStateOf(0f) } + var size by remember { mutableStateOf(IntSize.Zero) } + return this + .onGloballyPositioned { size = it.size } + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offsetX + translationY = offsetY + } + .pointerInput(Unit) { + awaitEachGesture { + // the initial pass, as the scroll of the same column is applied after this modifier and would take the gesture first + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + var taken: Boolean? = null + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + val multiTouch = event.changes.count { it.pressed } > 1 + if (multiTouch || scale > 1f) { + scale = (scale * event.calculateZoom()).coerceIn(1f, MAX_PAGE_ZOOM) + val pan = event.calculatePan() + // the page is scaled around its center, so it can be panned by half of the overflow in each direction + val maxX = size.width * (scale - 1f) / 2 + val maxY = size.height * (scale - 1f) / 2 + val pannedY = offsetY + pan.y * scale + // the clamp is applied even when the gesture is not taken: at scale 1 both bounds + // are 0, which resets the offsets after zooming back out + offsetX = (offsetX + pan.x * scale).coerceIn(-maxX, maxX) + offsetY = pannedY.coerceIn(-maxY, maxY) + // two fingers always mean zoom, taken without a touch slop: waiting for one would let + // the scroll reach its own slop first and scroll the page. A one finger drag is left + // to the scroll at the edges, decided once so it cannot alternate mid drag + if (multiTouch) taken = true + else if (taken == null && pan.y != 0f) taken = pannedY.absoluteValue < maxY + if (taken == true) event.changes.forEach { if (it.pressed) it.consume() } + } + } while (event.changes.any { it.pressed }) + } + } +} @Composable fun CreateUpdateAddressShortLinkView(modalManager: ModalManager) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 59136e90d2..314537f579 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -87,13 +87,19 @@ fun PrivacySettingsView( val currentUser = chatModel.currentUser.value if (currentUser != null && !chatModel.desktopNoUserNoRemote) { SectionDividerSpaced() - ContacRequestsFromGroupsSection( + AutoAcceptSection( currentUser = currentUser, - setAutoAcceptGrpDirectInvs = { enable -> + setAutoAcceptMemberContacts = { enable -> withApi { chatModel.controller.apiSetUserAutoAcceptMemberContacts(currentUser, enable) chatModel.currentUser.value = currentUser.copy(autoAcceptMemberContacts = enable) } + }, + setAutoAcceptGroupInvitations = { enable -> + withApi { + chatModel.controller.apiSetUserAutoAcceptGroupInvitations(currentUser, enable) + chatModel.currentUser.value = currentUser.copy(autoAcceptGroupInvitations = enable) + } } ) } @@ -333,16 +339,26 @@ expect fun PrivacyDeviceSection( ) @Composable -private fun ContacRequestsFromGroupsSection( +private fun AutoAcceptSection( currentUser: User, - setAutoAcceptGrpDirectInvs: (Boolean) -> Unit + setAutoAcceptMemberContacts: (Boolean) -> Unit, + setAutoAcceptGroupInvitations: (Boolean) -> Unit ) { - SectionView(stringResource(MR.strings.settings_section_title_contact_requests_from_groups)) { - SettingsActionItemWithContent(painterResource(MR.images.ic_check), stringResource(MR.strings.auto_accept_contact)) { + // legacy string key names, reused for their values so this section stays translated + SectionView(stringResource(MR.strings.auto_accept_contact)) { + SettingsActionItemWithContent(painterResource(MR.images.ic_person), stringResource(MR.strings.settings_section_title_contact_requests_from_groups)) { DefaultSwitch( checked = currentUser.autoAcceptMemberContacts, onCheckedChange = { enable -> - setAutoAcceptGrpDirectInvs(enable) + setAutoAcceptMemberContacts(enable) + } + ) + } + SettingsActionItemWithContent(painterResource(MR.images.ic_group), stringResource(MR.strings.group_invitations)) { + DefaultSwitch( + checked = currentUser.autoAcceptGroupInvitations, + onCheckedChange = { enable -> + setAutoAcceptGroupInvitations(enable) } ) } @@ -350,7 +366,7 @@ private fun ContacRequestsFromGroupsSection( SectionTextFooter( remember(currentUser.displayName) { buildAnnotatedString { - append(generalGetString(MR.strings.this_setting_is_for_your_current_profile) + " ") + append(generalGetString(MR.strings.these_settings_are_for_your_current_profile) + " ") withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(currentUser.displayName) } @@ -387,7 +403,7 @@ private fun DeliveryReceiptsSection( SectionTextFooter( remember(currentUser.displayName) { buildAnnotatedString { - append(generalGetString(MR.strings.receipts_section_description) + " ") + append(generalGetString(MR.strings.these_settings_are_for_your_current_profile) + " ") withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(currentUser.displayName) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 96f36da6d7..8c134cb361 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -29,8 +29,10 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrateFromDeviceView +import chat.simplex.common.views.onboarding.GetStakeView import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.onboarding.crowdfundingAvailable import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView import chat.simplex.res.MR @@ -110,6 +112,17 @@ fun SettingsLayout( AppShutdownItem() AppVersionItem(showVersion) } + + if (crowdfundingAvailable()) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.v7_0_invest)) { + SettingsActionItem( + painterResource(MR.images.ic_redeem), + stringResource(MR.strings.v7_0_crowdfunding), + { ModalManager.start.showModalCloseable(cardScreen = true) { close -> GetStakeView(fromSettings = true, close = close) } } + ) + } + } SectionBottomSpacer() } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 9d9ea0cadd..af10952449 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -1106,7 +1106,7 @@ انقر لتنشيط ملف التعريف. عزل النقل هذه السلسلة ليست رابط اتصال! - هذه الإعدادات لملف تعريفك الحالي + هذه الإعدادات لملف تعريفك الحالي يمكن تجاوزها في إعدادات الاتصال والمجموعة. انتهت مهلة اتصال TCP لحماية المنطقة الزمنية، تستخدم ملفات الصور / الصوت التوقيت العالمي المنسق (UTC). diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index ead51b31ea..d7e9fc936e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1206,6 +1206,7 @@ Stop sharing address? Stop sharing Auto-accept + Group invitations Sent to your contact after connection. Welcome message Enter welcome message… (optional) @@ -1557,7 +1558,7 @@ If you enter this passcode when opening the app, all app data will be irreversibly removed! Set passcode This setting is for your current profile - These settings are for your current profile + These settings are for your current profile They can be overridden in contact and group settings. Contacts Enable receipts? @@ -1602,7 +1603,7 @@ Chats Files Send delivery receipts to - Contact requests from groups + Contact requests in groups About Contact Support the project @@ -2736,9 +2737,10 @@ - opt-in to send link previews.\n- use SOCKS proxy if enabled.\n- prevent hyperlink phishing.\n- remove link tracking. Non-profit governance To make SimpleX Network last. - - - + You can now invest in SimpleX Chat! 🚀 + Crowdfunding on Wefunder. + Crowdfunding on Wefunder + Learn more on Wefunder SimpleX public names (BETA) Public names for your channel or business. Better channels 📢 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index 9676bc0199..83e10a81c3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -481,7 +481,7 @@ Въведи правилна парола. активирано за вас Въведи парола в търсенето - Тези настройки са за текущия ви профил + Тези настройки са за текущия ви профил Те могат да бъдат променени в настройките за всеки контакт и група. Инструменти за разработчици Деактивиране за всички diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index ce38afb836..1ab559c09c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -1946,7 +1946,7 @@ Si introduïu aquesta contrasenya en obrir l\'aplicació, totes les dades de l\'aplicació s\'eliminaran de manera irreversible. Si introduïu el vostre codi d\'autodestrucció mentre obriu l\'aplicació: Estableix codi - Aquesta configuració és per al vostre perfil actual + Aquesta configuració és per al vostre perfil actual Es pot canviar a la configuració de contacte i grup. No Configuració diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 5160d28feb..0fca3db40e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -1246,7 +1246,7 @@ vyžadováno opětovné vyjednávání šifrování pro %s Odesílání potvrzení o doručení je vypnuto pro %d kontakty. Odesílání potvrzení o doručení bude povoleno pro všechny kontakty ve všech viditelných profilech chatu. - Toto nastavení je pro váš aktuální profil + Toto nastavení je pro váš aktuální profil opětovné vyjednávání šifrování povoleno opětovné vyjednávání šifrování povoleno pro %s vyžadováno opětovné vyjednávání šifrování diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index 9774107fcf..b3490abc26 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1364,7 +1364,7 @@ Nach ungelesenen und favorisierten Chats filtern. Das Senden von Empfangsbestätigungen an alle Kontakte in allen sichtbaren Chat-Profilen wird aktiviert. Das Senden von Bestätigungen an %d Kontakte ist deaktiviert - Diese Einstellungen gelten für Ihr aktuelles Chat-Profil + Diese Einstellungen gelten für Ihr aktuelles Chat-Profil Sie können in den Kontakt- und Gruppeneinstellungen überschrieben werden. Kontakte Bestätigungen deaktivieren\? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 390913c5f7..d9feb33f1a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -1350,7 +1350,7 @@ Ο αποστολέας ΔΕΝ θα ειδοποιηθεί. Οι διακομιστές για τις νέες συνδέσεις του τρέχοντος προφίλ συνομιλίας σου Οι διακομιστές για τα νέα αρχεία του τρέχοντος προφίλ συνομιλίας σου - Αυτές οι ρυθμίσεις ισχύουν για το τρέχον προφίλ σου + Αυτές οι ρυθμίσεις ισχύουν για το τρέχον προφίλ σου Το κείμενο που επικόλλησες δεν είναι σύνδεσμος SimpleX. Το αρχείο της βάσης δεδομένων που μεταφορτώθηκε, θα διαγραφεί οριστικά από τους διακομιστές. Το βίντεο δεν μπορεί να αποκωδικοποιηθεί. Δοκίμασε ένα άλλο βίντεο ή επικοινώνησε με τους προγραμματιστές. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index 4e3ce8e810..a8cf66f595 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -1283,7 +1283,7 @@ El envío de confirmaciones está desactivado para %d contactos El envío de confirmaciones está activado para %d contactos Enviar confirmaciones - Esta configuración afecta a tu perfil actual + Esta configuración afecta a tu perfil actual Activar ¿Desactivar confirmaciones\? ¿Activar confirmaciones\? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index 263b36d404..6e119a456a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -756,7 +756,7 @@ نام نمایشی جدید: اگر کد عبور خودتخریبی خود را زمان باز کردن برنامه وارد کنید: تمام اطلاعات برنامه حذف می‌شود. - این تنظیمات برای پروفایل فعلی شما هستند + این تنظیمات برای پروفایل فعلی شما هستند ارسال رسید برای %d مخاطب فعال است غیرفعال برای همه فعال برای همه گروه‌ها diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index f3c7a95ef4..7ae3f506a2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -1272,7 +1272,7 @@ Uudelleenneuvottele Uudelleenneuvottele salaus\? Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin! - Nämä asetukset koskevat nykyistä profiiliasi + Nämä asetukset koskevat nykyistä profiiliasi Ne voidaan ohittaa kontakti- ja ryhmäasetuksissa. salaus ok salauksen uudelleenneuvottelu sallittu diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index 845fe28404..77843d63ae 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -1280,7 +1280,7 @@ code de sécurité modifié L\'envoi d\'accusés de réception sera activé pour tous les contacts dans tous les profils de chat visibles. Ils peuvent être modifiés dans les paramètres des contacts et des groupes. - Ces paramètres s\'appliquent à votre profil actuel + Ces paramètres s\'appliquent à votre profil actuel Vous pouvez les activer ultérieurement via les paramètres de Confidentialité et Sécurité de l\'application. Activer les accusés de réception \? Désactiver les accusés de réception \? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index f9e627b21a..886da8993b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -1475,7 +1475,7 @@ A jelmondat a beállításokban egyszerű szövegként van tárolva. Konzol megjelenítése új ablakban Az előző üzenet kivonata különbözik. - Ezek a beállítások csak a jelenlegi csevegési profiljára vonatkoznak + Ezek a beállítások csak a jelenlegi csevegési profiljára vonatkoznak Várjon, amíg a fájl betöltődik a társított hordozható eszközről GitHub-tárolónkban talál.]]> Hiba történt a tartalom megjelenítésekor diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index bd3f6e2b22..783b1a94c6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -1206,7 +1206,7 @@ Aktifkan tanda terima untuk grup? Profil obrolan kosong dengan nama yang disediakan dibuat, dan aplikasi terbuka seperti biasa. Jika Anda memasukkan kode sandi saat membuka aplikasi, semua data aplikasi akan dihapus secara permanen! - Pengaturan ini untuk profil Anda saat ini + Pengaturan ini untuk profil Anda saat ini Kirim tanda terima diaktifkan untuk %d kontak Kirim tanda terima dimatikan untuk %d kontak Gagal memuat server XFTP diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 9fad882019..01f4d5c3c2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -1290,7 +1290,7 @@ L\'invio delle ricevute di consegna sarà attivo per tutti i contatti in tutti i profili di chat visibili. L\'invio di ricevute è disattivato per %d contatti La crittografia funziona e il nuovo accordo sulla crittografia non è richiesto. Potrebbero verificarsi errori di connessione! - Queste impostazioni sono per il tuo profilo attuale + Queste impostazioni sono per il tuo profilo attuale Possono essere sovrascritte nelle impostazioni dei contatti e dei gruppi. Attiva per tutti Attiva (mantieni sostituzioni) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index 430acfa6c1..ea9e504e98 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1291,7 +1291,7 @@ \n- קבוצות קצת יותר טובות. \n- ועוד! שליחת קבלות שליחה תתאפשר עבור כל אנשי הקשר בכל פרופילי הצ\'אט הגלויים. - הגדרות אלו מיועדות לפרופיל הנוכחי שלך + הגדרות אלו מיועדות לפרופיל הנוכחי שלך ניתן לעקוף אותם בהגדרות אנשי קשר וקבוצות. שליחת קבלות מושבתת עבור %d אנשי קשר שליחת קבלות מאופשרת עבור %d אנשי קשר diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index e61e4e4372..af501f9b20 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -1278,7 +1278,7 @@ グループメンバーによる修正はサポートされていません 連絡先 これらは連絡先とグループの設定が優先されます。 - これらの設定は現在のプロファイル用です + これらの設定は現在のプロファイル用です 配信通知を有効? %s : %s 接続を修正 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index aceb11ecf8..87a0afa005 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -1198,7 +1198,7 @@ %1$s!]]> Kad prisijungti su nuoroda Ši nuoroda nėra tinkama prisijungimo nuoroda! - Šie nustatymai yra jūsų dabartiniam profiliui + Šie nustatymai yra jūsų dabartiniam profiliui Slaptafrazė saugoma nustatymuose kaip paprastas tekstas. Bakstelėkite, kad prisijungti kaip inkognito Ši grupė turi daugiau nei %1$d narių, pristatymo kvitai nėra siunčiami. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml index c5473aeea4..26b4c51aa3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml @@ -1607,7 +1607,7 @@ Ievada tīkla operatori turpināt Ienākošais video zvans Ienākošais audio zvans - Čeki + Čeki Čeku apraksts 1 Čeku kontakti Čeku kontakti iespējot diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index 307ffc78fa..d162b8a44d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -1278,7 +1278,7 @@ Ontvangst bevestiging uitschakelen\? Ontvangst bevestiging inschakelen\? Het verzenden van ontvangst bevestiging is ingeschakeld voor %d-contactpersonen - Deze instellingen gelden voor uw huidige profiel + Deze instellingen gelden voor uw huidige profiel Uitschakelen (overschrijvingen behouden) Inschakelen voor iedereen Inschakelen (overschrijvingen behouden) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 0cf195510b..a25728ccaf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -1287,7 +1287,7 @@ Renegocjować szyfrowanie\? Wysyłanie potwierdzeń jest włączone dla %d kontaktów Szyfrowanie działa, a nowe uzgodnienie szyfrowania nie jest wymagane. Może to spowodować błędy w połączeniu! - Te ustawienia dotyczą Twojego bieżącego profilu + Te ustawienia dotyczą Twojego bieżącego profilu Można je nadpisać w ustawieniach kontaktu i grupy. szyfrowanie ok renegocjacja szyfrowania dozwolona diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index cbf69db5d4..41e08643ff 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -1520,7 +1520,7 @@ bloqueado O código que você escaneou não é um QR code SimpleX. O navegador padrão é necessário para chamadas. Configure o navegador padrão no sistema e compartilhe mais informações com os desenvolvedores. - Essas configurações são para o seu perfil atual + Essas configurações são para o seu perfil atual O vídeo não pode ser decodificado. Por favor, tente com um vídeo diferente ou contate os desenvolvedores. Este é o seu próprio link de uso único! Tempo limite atingido durante a conexão com o desktop diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index ae0a98b44e..f691857fd3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -1999,7 +1999,7 @@ Pentru a efectua apeluri, permiteți utilizarea microfonului. Încheiați apelul și încercați să sunați din nou. Când sunt activați mai mulți operatori, niciunul dintre ei nu are metadate pentru a afla cine comunică cu cine. Video pornit - Aceste setări sunt pentru profilul tău actual + Aceste setări sunt pentru profilul tău actual Da Coada Utilizare de pe desktop diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index dae2a494dc..bd4d3c39ff 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1329,7 +1329,7 @@ Отправка отчётов о доставке включена для %d контактов Отправка отчётов о доставке будет включена для всех контактов во всех видимых профилях чата. Установка для Вашего активного профиля - Установки для Вашего активного профиля + Установки для Вашего активного профиля Отправка отчётов о доставке выключена для %d контактов Шифрование работает, и новое соглашение не требуется. Это может привести к ошибкам соединения! Вторая галочка - знать, что доставлено! ✅ diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index c7313e76a3..ddc258490f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -1280,7 +1280,7 @@ เร็วๆ นี้! อนุญาตให้มีการเจรจา encryption อีกครั้งสําหรับ %s %s: %s - การตั้งค่าเหล่านี้ใช้สำหรับโปรไฟล์ปัจจุบันของคุณ + การตั้งค่าเหล่านี้ใช้สำหรับโปรไฟล์ปัจจุบันของคุณ สามารถลบล้างได้ในการตั้งค่าผู้ติดต่อและกลุ่ม ปิดใช้งาน (เก็บการแทนที่) เปิดใช้งาน (เก็บการแทนที่) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index a1ead8c55e..2fff0e4082 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -1204,7 +1204,7 @@ SimpleX Kilit aktif değil! SimpleX Kilit Doğrudan bağlanılsın mı? - Bu ayarlar mevcut profiliniz içindir + Bu ayarlar mevcut profiliniz içindir Sunucu testi başarısız! Bağlantıyı onayla Yeni sohbet başlat diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 9a59fec674..439be068fa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -1271,7 +1271,7 @@ Скасувати Виберіть файл Контакти - Ці налаштування стосуються вашого поточного профілю + Ці налаштування стосуються вашого поточного профілю Вимкнути повідомлення про доставку? Увімкнути повідомлення про доставку? можлива перезапис шифрування diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index c285722200..5d97b21a1f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -1972,7 +1972,7 @@ Văn bản bạn vừa dán không phải là một đường dẫn SimpleX. Chức vụ sẽ được đổi thành %s. Thành viên sẽ nhận được một lời mời mới. Mật khẩu sẽ được lưu trữ trong cài đặt dưới dạng thuần văn bản sau khi bản đổi nó hoặc khởi động lại ứng dụng. - Các cài đặt này là cho hồ sơ trò chuyện hiện tại của bạn + Các cài đặt này là cho hồ sơ trò chuyện hiện tại của bạn Chức vụ sẽ được đổi thành %s. Tất cả mọi người trong nhóm sẽ được thông báo. Bản lưu trữ cơ sở dữ liệu đã được tải lên sẽ bị xóa vĩnh viễn khỏi các máy chủ. Việc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể khôi phục. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 9dea7a5a0a..957d051898 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -1344,7 +1344,7 @@ 连接请求将发送给该群成员。 密码以明文形式存储在设置中。 同步连接时出错 - 这些设置适用于你当前的个人资料 + 这些设置适用于你当前的个人资料 允许为 %s 重新协商加密 为所有人启用 需要重新协商加密 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index c6cf22f427..ad82cb8329 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -2444,7 +2444,7 @@ 未使用 Tor 或 VPN 時,你的 IP 地址會對檔案伺服器可見。 移除連結追蹤 此設定適用於你目前的個人檔案 - 這些設定適用於你目前的個人檔案 + 這些設定適用於你目前的個人檔案 可在聯絡人和群組設定中覆寫這些設定。 已為 %d 個聯絡人啟用送達回條 已為 %d 個聯絡人停用送達回條 diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_1.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_1.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_2.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_2.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_3.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_3.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_4.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_4.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_4.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt index c2f37fd5d9..f5bba2d344 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt @@ -23,6 +23,7 @@ import javax.swing.SwingUtilities internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVideoSurfaceAdapter()) { private val videoSurface = SkiaBitmapVideoSurface() + @Volatile private var mediaPlayer: MediaPlayer? = null private lateinit var imageInfo: ImageInfo private lateinit var frameBytes: ByteArray private val skiaBitmap: Bitmap = Bitmap() @@ -31,6 +32,7 @@ internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVid val bitmap: State = composeBitmap override fun attach(mediaPlayer: MediaPlayer) { + this.mediaPlayer = mediaPlayer videoSurface.attach(mediaPlayer) } @@ -39,9 +41,17 @@ internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVid private var sourceHeight: Int = 0 override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat { - this.sourceWidth = sourceWidth - this.sourceHeight = sourceHeight - return RV32BufferFormat(sourceWidth, sourceHeight) + // libvlc passes the size the decoder padded the picture to, not the size of the picture (dav1d + // pads to a multiple of 128, so 1920x1080 arrives as 1920x1152), and vlc stretches the picture to + // fill whatever size is returned. Ask for the size of the track being played instead. The format + // is negotiated more than once, and vlc has not selected the track yet on the first calls + val player = mediaPlayer + val tracks = player?.media()?.info()?.videoTracks() + val playingTrack = player?.video()?.track() + val track = tracks?.firstOrNull { it.id() == playingTrack } ?: tracks?.singleOrNull() + this.sourceWidth = track?.width()?.takeIf { it > 0 } ?: sourceWidth + this.sourceHeight = track?.height()?.takeIf { it > 0 } ?: sourceHeight + return RV32BufferFormat(this.sourceWidth, this.sourceHeight) } override fun allocatedBuffers(buffers: Array) { diff --git a/apps/multiplatform/spec/database.md b/apps/multiplatform/spec/database.md index f6ecedb721..8981fe7055 100644 --- a/apps/multiplatform/spec/database.md +++ b/apps/multiplatform/spec/database.md @@ -345,7 +345,7 @@ class ArchiveConfig( ### Import Flow 1. User selects an archive file. -2. UI copies it to a temp location and constructs an `ArchiveConfig`. +2. UI copies it into `databaseExportDir` and constructs an `ArchiveConfig`. The destination is confined to that folder: `getFileName` returns a bare file name on every platform, and `saveArchiveFromURI` checks the canonical destination before copying. 3. Calls `apiImportArchive(config)` which sends `CC.ApiImportArchive` to the Haskell core. 4. The core extracts and replaces both databases. 5. Returns `CR.ArchiveImported` with a list of `ArchiveError` (non-fatal issues during import). diff --git a/apps/simplex-directory-service/README.md b/apps/simplex-directory-service/README.md index 5c397b6492..09df74abb0 100644 --- a/apps/simplex-directory-service/README.md +++ b/apps/simplex-directory-service/README.md @@ -143,11 +143,12 @@ The bot sends a welcome message automatically when you connect. ### 2. Registering a Group -Registration is a three-step process — see [DIRECTORY.md](../../docs/DIRECTORY.md) for full details: +Registration is a two-step process — see [DIRECTORY.md](../../docs/DIRECTORY.md) for full details: 1. Invite the directory bot to your group as `admin`. -2. Add the link the bot sends you to the group's welcome message. -3. Wait for admin approval (usually within a day, except holidays). +2. Wait for admin approval (usually within a day, except holidays). + +On approval the bot creates the join link, sends it to you, and recommends adding it to the group welcome message. Adding or removing this link in the welcome message keeps the group listed; other profile changes require re-approval. If a group with the same display name is already registered (but not yet listed or suspended), the bot asks you to confirm with `/confirm`. If the name is already listed or suspended in the directory, registration is blocked. @@ -277,33 +278,29 @@ Forward path, from invitation to being listed: └────────────┬─────────────┘ ▼ Proposed - │ bot joins the group and creates the link - ▼ - PendingUpdate - │ owner adds the link to the group welcome + │ bot joins the group ▼ PendingApproval - │ admin runs /approve + │ admin runs /approve; the bot creates the join link ▼ Active (listed; visible in search) ``` **Transitions out of Active:** -- → **PendingUpdate** — the directory bot link is removed from the welcome message. -- → **PendingApproval** — most other profile changes (see ** below); the `approval-id` shown to admins is bumped each time, so stale `/approve` commands are rejected. +- → **PendingApproval** — profile changes other than the bot link (see ** below); the `approval-id` shown to admins is bumped each time, so stale `/approve` commands are rejected. - → **Suspended** — an admin runs `/suspend`; `/resume` re-lists the group. - → **SuspendedBadRoles** — the directory bot loses its `admin` role, or the registering owner loses their `owner` role, in the group; automatically restored to **Active** once the roles are corrected. - → **Removed** — the owner runs `/delete`, the owner is removed from or leaves the group, the bot is removed from the group, or the group is deleted. The group can be re-registered afterwards. \* Only when the duplicate is registered but not yet listed or suspended. If the name is already listed or suspended, registration is blocked entirely. -\*\* Profile changes only trigger re-approval when fields other than the directory bot link are modified. If the only change is swapping the old bot link for the new one, or changing only whitespace in the description, the group stays Active. +\*\* Profile changes only trigger re-approval when fields other than the directory bot link are modified. Adding, removing, or replacing the bot link line in the welcome message, or changing only whitespace in the description, keeps the group Active. **State notes:** - **PendingConfirmation** — the bot was invited but a group with the same display name is already registered (in a pending state); the owner must run `/confirm` to proceed. - **Proposed** — the name is unique (or the duplicate was confirmed via `/confirm`); the bot is joining the group. -- **PendingUpdate** — the bot has joined the group and created the join link; the owner must add it to the group's welcome message. -- **PendingApproval** — submitted for admin review. The join link works even before approval. +- **PendingUpdate** — legacy state of registrations created before link-at-approval; any profile change moves such a group to PendingApproval. +- **PendingApproval** — submitted for admin review. The join link is created at first approval, so a new registration has no working link until approved. - **Active** — listed in the directory and visible in search results. diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 3bff611a28..bc84ab86e1 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -54,10 +54,10 @@ data DirectoryEvent | DEPendingMember GroupInfo GroupMember | DEPendingMemberMsg GroupInfo GroupMember ChatItemId Text | DEGroupItemProhibited GroupInfo GroupMember ChatItemId GroupFeature -- a member posted content prohibited by the group's settings - | DEContactRoleChanged GroupInfo ContactId GroupMemberRole -- contactId here is the contact whose role changed + | DEContactRoleChanged GroupInfo ContactId GroupMemberId GroupMemberRole -- contactId/memberId here identify the member whose role changed | DEServiceRoleChanged GroupInfo GroupMemberRole - | DEContactRemovedFromGroup ContactId GroupInfo - | DEContactLeftGroup ContactId GroupInfo + | DEContactRemovedFromGroup ContactId GroupMemberId GroupInfo + | DEContactLeftGroup ContactId GroupMemberId GroupInfo | DEServiceRemovedFromGroup GroupInfo | DEGroupDeleted GroupInfo | DEChatLinkReceived {contact :: Contact, chatItemId :: ChatItemId, chatLink :: MsgChatLink, ownerSig :: Maybe LinkOwnerSig} @@ -93,9 +93,9 @@ crDirectoryEvent_ = \case _ -> Nothing CEvtMemberRole {groupInfo, member, toRole} | groupMemberId' member == groupMemberId' (membership groupInfo) -> Just $ DEServiceRoleChanged groupInfo toRole - | otherwise -> (\ctId -> DEContactRoleChanged groupInfo ctId toRole) <$> memberContactId member - CEvtDeletedMember {groupInfo, deletedMember} -> (`DEContactRemovedFromGroup` groupInfo) <$> memberContactId deletedMember - CEvtLeftMember {groupInfo, member} -> (`DEContactLeftGroup` groupInfo) <$> memberContactId member + | otherwise -> (\ctId -> DEContactRoleChanged groupInfo ctId (groupMemberId' member) toRole) <$> memberContactId member + CEvtDeletedMember {groupInfo, deletedMember} -> (\ctId -> DEContactRemovedFromGroup ctId (groupMemberId' deletedMember) groupInfo) <$> memberContactId deletedMember + CEvtLeftMember {groupInfo, member} -> (\ctId -> DEContactLeftGroup ctId (groupMemberId' member) groupInfo) <$> memberContactId member CEvtDeletedMemberUser {groupInfo} -> Just $ DEServiceRemovedFromGroup groupInfo CEvtGroupDeleted {groupInfo} -> Just $ DEGroupDeleted groupInfo CEvtUnknownMemberAnnounced {groupInfo, unknownMember, announcedMember} -> Just $ DEMemberUpdated {groupInfo, fromMember = unknownMember, toMember = announcedMember} diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs index d2df341545..dd7bbb509a 100644 --- a/apps/simplex-directory-service/src/Directory/Listing.hs +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -1,5 +1,6 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -34,12 +35,30 @@ import Data.Time.Format.ISO8601 (iso8601Show) import Directory.Store import Simplex.Chat.Markdown import Simplex.Chat.Types +import Simplex.Chat.View (simplexChatContact) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) import System.Directory import System.FilePath +-- the line the directory recommends adding to the group welcome message +groupLinkLine :: Text -> Text -> Text +groupLinkLine name link = groupLinkLinePrefix name <> link + +groupLinkLinePrefix :: Text -> Text +groupLinkLinePrefix name = "Link to join the group " <> name <> ": " + +matchesGroupLink :: CreatedLinkContact -> FormattedText -> Bool +matchesGroupLink (CCLink cReq sLnk_) = \case + FormattedText (Just SimplexLink {simplexUri = ACL SCMContact cLink}) _ -> case cLink of + CLFull cReq' -> sameConnReqContact cReq' cReq + CLShort sLnk' -> maybe False (sameShortLinkContact sLnk') sLnk_ + _ -> False + +descriptionContainsLink :: CreatedLinkContact -> Text -> Bool +descriptionContainsLink gLink = maybe False (any (matchesGroupLink gLink)) . parseMaybeMarkdownList + directoryDataPath :: String directoryDataPath = "data" @@ -107,7 +126,13 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma let gtStr = case gt' of GTChannel -> "channel"; _ -> "group" linkLine = "Link to join the " <> gtStr <> " " <> displayName <> ": " <> decodeUtf8 (strEncode sLnk) in Just $ maybe linkLine (<> "\n\n" <> linkLine) description - Nothing -> description + Nothing -> case connLinkContact <$> gLink_ of + Just gLink@(CCLink cReq sLnk_) + | not (maybe False (descriptionContainsLink gLink) description) -> + let linkText = maybe (strEncode $ simplexChatContact cReq) strEncode sLnk_ + linkLine = groupLinkLine displayName $ decodeUtf8 linkText + in Just $ maybe linkLine (<> "\n\n" <> linkLine) description + _ -> description entry groupLink = let de = DirectoryEntry diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 7dc165c4df..61ed7e66af 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -25,13 +25,14 @@ import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import Control.Monad.Reader (runReaderT) import qualified Data.Attoparsec.Text as A import Data.Bifunctor (first) import Data.Either (fromRight) -import Data.List (find, intercalate) +import Data.List (intercalate) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M -import Data.Maybe (fromMaybe, isJust, isNothing, maybeToList) +import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, maybeToList) import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T @@ -51,6 +52,7 @@ import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller import Simplex.Chat.Core +import Simplex.Chat.Library.Internal (setGroupLinkData) import Simplex.Chat.Markdown (Format (..), FormattedText (..), SimplexLinkType (..), parseMaybeMarkdownList, viewName) import Simplex.Chat.Messages import Simplex.Chat.Options @@ -65,7 +67,8 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.View (groupSimplexDomain, serializeChatError, serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) -import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ACreatedConnLink (..), AgentErrorType (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), SimplexDomain, sameConnReqContact, sameShortLinkContact) +import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ACreatedConnLink (..), AgentErrorType (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), SimplexDomain) +import Simplex.Messaging.Client (NetworkRequestMode (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (ErrorType (..)) @@ -78,13 +81,6 @@ import System.Exit (exitFailure) import System.Process (readProcess) import Text.Read (readMaybe) -data GroupProfileUpdate - = GPNoServiceLink - | GPServiceLinkAdded {linkNow :: Text} - | GPServiceLinkRemoved - | GPHasServiceLink {linkBefore :: Text, linkNow :: Text} - | GPServiceLinkError - data DuplicateGroup = DGUnique -- display name or full name is unique | DGRegistered -- the group with the same names is registered, additional confirmation is required @@ -166,7 +162,7 @@ directoryServiceCLI opts = do acceptMember = Just $ acceptMemberHook opts env } raceAny_ $ - [ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing, + [ simplexChatCLI' terminalChatConfig {chatHooks, updateGroupLinksFromApp = True} (mkChatOpts opts) Nothing, processEvents env ] <> maybeToList (updateListingsThread_ opts env) @@ -258,7 +254,7 @@ directoryService opts cfg = do postStartHook = Just $ directoryPostStartHook opts env, acceptMember = Just $ acceptMemberHook opts env } - simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \user cc -> + simplexChatCore cfg {chatHooks, updateGroupLinksFromApp = True} (mkChatOpts opts) $ \user cc -> raceAny_ $ [ forever $ do (_, resp) <- atomically . readTBQueue $ outputQ cc @@ -327,10 +323,10 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o DEPendingMember g m -> dePendingMember g m DEPendingMemberMsg g m ciId t -> dePendingMemberMsg g m ciId t DEGroupItemProhibited g m ciId gf -> when prohibitedToObserver $ deGroupItemProhibited g m ciId gf - DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role + DEContactRoleChanged g ctId gmId role -> deContactRoleChanged g ctId gmId role DEServiceRoleChanged g role -> deServiceRoleChanged g role - DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g - DEContactLeftGroup ctId g -> deContactLeftGroup ctId g + DEContactRemovedFromGroup ctId gmId g -> deContactRemovedFromGroup ctId gmId g + DEContactLeftGroup ctId gmId g -> deContactLeftGroup ctId gmId g DEServiceRemovedFromGroup g -> deServiceRemovedFromGroup g DEGroupDeleted g -> deGroupDeleted g DEChatLinkReceived {contact = ct, chatLink, ownerSig} -> deChatLinkReceived ct chatLink ownerSig @@ -354,6 +350,15 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o notifyAdminUsers s = withAdminUsers $ \contactId -> sendMessage' cc contactId s notifyOwner = sendMessage' cc . dbContactId ctId `isOwner` GroupReg {dbContactId} = ctId == dbContactId + -- Whether the leaving/removed/role-changed member is the registration owner. + -- Comparing by member id (not contact id) is required because a non-owner + -- member can be associated with the owner's contact by the probe-and-merge + -- mechanism, which would otherwise make its departure de-list the group. + -- Registrations recorded before owner_member_id existed keep the contact-id + -- comparison. + isOwnerMember :: GroupReg -> GroupMemberId -> ContactId -> Bool + isOwnerMember GroupReg {dbContactId, dbOwnerMemberId} gmId ctId = + ctId == dbContactId && maybe True (gmId ==) dbOwnerMemberId withGroupReg :: GroupInfo -> Text -> (GroupReg -> IO ()) -> IO () withGroupReg GroupInfo {groupId, localDisplayName} err action = getGroupReg cc groupId >>= \case @@ -492,33 +497,18 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o let msg = "Error updating group " <> tshow groupId <> " owner: " <> T.pack e logError msg notifyOwner gr msg - Right () -> do - notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" - sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case - Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} -> - setGroupStatus notifyAdminUsers env cc groupId GRSPendingUpdate $ \gr' -> do - notifyOwner - gr' - "Created the public link to join the group via this directory service that is always online.\n\n\ - \Please add it to the group welcome message.\n\ - \For example, add:" - notifyOwner gr' $ "Link to join the group " <> displayName <> ": " <> groupLinkText gLink - notifyOwner gr' $ recommendedSettingsNotice (userGroupRegId gr') - Left (ChatError e) -> case e of - CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." - CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." - CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" - CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active" - _ -> notifyOwner gr $ unexpectedError "can't create group link" - _ -> notifyOwner gr $ unexpectedError "can't create group link" + Right () -> + setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval 1) $ \gr' -> do + notifyOwner gr' $ "Joined the group " <> displayName <> ". Registration is pending approval — it may take up to 48 hours." + notifyOwner gr' $ recommendedSettingsNotice (userGroupRegId gr') + verifyAndSendToApprove g gr' 1 deGroupUpdated :: GroupMember -> GroupInfo -> GroupInfo -> IO () deGroupUpdated m@GroupMember {memberProfile = LocalProfile {displayName = mName}} fromGroup toGroup = do logInfo $ "group updated " <> viewGroupName toGroup unless (sameProfile p p') $ do withGroupReg toGroup "group updated" $ \gr@GroupReg {groupRegStatus} -> do - let userGroupRef = userGroupReference gr toGroup - byMember = case memberContactId m of + let byMember = case memberContactId m of Just ctId | ctId `isOwner` gr -> "" -- group registration owner, not any group owner. _ -> " by " <> mName -- owner notification from directory will include the name. case publicGroup p' of @@ -529,26 +519,11 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o Nothing -> case groupRegStatus of GRSPendingConfirmation -> pure () GRSProposed -> pure () - GRSPendingUpdate -> - groupProfileUpdate >>= \case - GPNoServiceLink -> - notifyOwner gr $ "The profile updated for " <> userGroupRef <> byMember <> ", but the group link is not added to the welcome message." - GPServiceLinkAdded _ -> groupLinkAdded gr byMember - GPServiceLinkRemoved -> - notifyOwner gr $ - "The group link of " <> userGroupRef <> " is removed from the welcome message" <> byMember <> ", please add it." - GPHasServiceLink {} -> groupLinkAdded gr byMember - GPServiceLinkError -> do - notifyOwner gr $ - ("Error: " <> serviceName <> " has no group link for " <> userGroupRef) - <> " after profile was updated" - <> byMember - <> ". Please report the error to the developers." - logError $ "Error: no group link for " <> userGroupRef - GRSPendingApproval n -> processProfileChange gr byMember False $ n + 1 - GRSActive -> processProfileChange gr byMember True 1 - GRSSuspended -> processProfileChange gr byMember False 1 - GRSSuspendedBadRoles -> processProfileChange gr byMember False 1 + GRSPendingUpdate -> sendForApproval byMember 1 + GRSPendingApproval n -> processProfileChange gr byMember $ n + 1 + GRSActive -> processProfileChange gr byMember 1 + GRSSuspended -> processProfileChange gr byMember 1 + GRSSuspendedBadRoles -> processProfileChange gr byMember 1 GRSRemoved -> pure () where GroupInfo {groupId, groupProfile = p} = fromGroup @@ -583,73 +558,46 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o Nothing -> logError $ "no owner member set for " <> groupRef _ -> setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n') (`updatedNotification` toGroup) - groupLinkAdded gr byMember = - getDuplicateGroup toGroup >>= \case - Left e -> notifyOwner gr $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e - Right DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup - _ -> setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval gaId) $ \gr' -> do - notifyOwner gr' $ - ("Thank you! The group link for " <> userGroupReference gr' toGroup <> " is added to the welcome message" <> byMember) - <> ".\nYou will be notified once the group is added to the directory - it may take up to 48 hours." - checkRolesSendToApprove gr' gaId - where - gaId = 1 - processProfileChange gr byMember isActive n' = do - let userGroupRef = userGroupReference gr toGroup - groupRef = groupReference toGroup - groupProfileUpdate >>= \case - GPNoServiceLink -> setGroupStatus notifyAdminUsers env cc groupId GRSPendingUpdate $ \gr' -> do - notifyOwner gr' $ - ("The group profile is updated for " <> userGroupRef <> byMember <> ", but no link is added to the welcome message.\n\n") - <> "The group will remain hidden from the directory until the group link is added and the group is re-approved." - GPServiceLinkRemoved -> setGroupStatus notifyAdminUsers env cc groupId GRSPendingUpdate $ \gr' -> do - notifyOwner gr' $ - ("The group link for " <> userGroupRef <> " is removed from the welcome message" <> byMember) - <> ".\n\nThe group is hidden from the directory until the group link is added and the group is re-approved." - notifyAdminUsers $ "The group link is removed from " <> groupRef <> ", de-listed." - GPServiceLinkAdded _ -> setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n') $ \gr' -> do - notifyOwner gr' $ - ("The group link is added to " <> userGroupRef <> byMember) - <> "!\nIt is hidden from the directory until approved." - notifyAdminUsers $ "The group link is added to " <> groupRef <> byMember <> "." - checkRolesSendToApprove gr n' - GPHasServiceLink {linkBefore, linkNow} - | isActive && onlyLinkChanged p p' -> do - notifyOwner gr $ - ("The group " <> userGroupRef <> " is updated" <> byMember) - <> "!\nThe group is listed in directory." - notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory." - | otherwise -> setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n') $ \gr' -> do - notifyOwner gr' $ - ("The group " <> userGroupRef <> " is updated" <> byMember) - <> "!\nIt is hidden from the directory until approved." - notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> "." - checkRolesSendToApprove gr' n' - where - onlyLinkChanged - GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d, memberAdmission = ma} - GroupProfile {displayName = dn', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma'} = - dn == dn' && fn == fn' && i == i' && sd == sd' && ma == ma' && (T.words . T.replace linkBefore "" <$> d) == (T.words . T.replace linkNow "" <$> d') - GPServiceLinkError -> logError $ "Error: no group link for " <> groupRef <> " pending approval." - groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) + sendForApproval byMember n' = + setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n') $ \gr' -> do + notifyOwner gr' $ + ("The group " <> userGroupReference gr' toGroup <> " is updated" <> byMember) + <> "!\nIt is hidden from the directory until approved." + notifyAdminUsers $ "The group " <> groupReference toGroup <> " is updated" <> byMember <> "." + checkRolesSendToApprove gr' n' + processProfileChange gr byMember n' = + withDB' "getGroupLink" cc (\db -> runExceptT $ getGroupLink db user toGroup) >>= \case + Left e -> linkReadError $ T.pack e + Right (Left SEGroupLinkNotFound {}) -> profileChange Nothing + Right (Left e) -> linkReadError $ tshow e + Right (Right gLink) -> profileChange $ Just gLink where - profileUpdate = \case - Right CRGroupLink {groupLink = GroupLink {connLinkContact = CCLink cr sl_}} -> - let linkBefore_ = profileGroupLinkText fromGroup - linkNow_ = profileGroupLinkText toGroup - profileGroupLinkText GroupInfo {groupProfile = GroupProfile {description = descr_}} = - maybe Nothing (fmap (\(FormattedText _ t) -> t) . find ftHasLink) $ parseMaybeMarkdownList =<< descr_ - ftHasLink = \case - FormattedText (Just SimplexLink {simplexUri = ACL SCMContact cLink}) _ -> case cLink of - CLFull cr' -> sameConnReqContact cr' cr - CLShort sl' -> maybe False (sameShortLinkContact sl') sl_ - _ -> False - in case (linkBefore_, linkNow_) of - (Just linkBefore, Just linkNow) -> GPHasServiceLink linkBefore linkNow - (Just _, Nothing) -> GPServiceLinkRemoved - (Nothing, Just linkNow) -> GPServiceLinkAdded linkNow - (Nothing, Nothing) -> GPNoServiceLink - _ -> GPServiceLinkError + linkReadError e = logError $ "Error reading group link for " <> groupReference toGroup <> ": " <> e + profileChange gLink_ + | not (linkOnlyChange gLink_) = sendForApproval byMember n' + | groupRegStatus gr == GRSActive = do + notifyOwner gr $ + ("The group " <> userGroupReference gr toGroup <> " is updated" <> byMember) + <> "!\nThe group is listed in directory." + notifyAdminUsers $ "The group " <> groupReference toGroup <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory." + forM_ gLink_ $ \gLink -> + updateGroupLinkData cc user toGroup gLink >>= \case + Right _ -> pure () + Left e -> logError $ "Error updating group link data for " <> groupReference toGroup <> ": " <> tshow e + | otherwise = pure () + linkOnlyChange gLink_ = + dn == dn' && fn == fn' && i == i' && sd == sd' && ma == ma' && descrWords d == descrWords d' + where + GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d, memberAdmission = ma} = p + GroupProfile {displayName = dn', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma'} = p' + -- drop the recommended link line (link token and prefix) so adding or removing it is not a content change + descrWords = maybe [] $ case gLink_ of + Just GroupLink {connLinkContact} -> + T.words . T.replace (groupLinkLinePrefix dn) "" . withoutLink connLinkContact + Nothing -> T.words + withoutLink gl descr = + maybe descr (T.concat . map ftText . filter (not . matchesGroupLink gl)) $ parseMaybeMarkdownList descr + ftText (FormattedText _ t) = t checkRolesSendToApprove gr gaId = do (badRolesMsg <$$> getGroupRolesStatus toGroup gr) >>= \case Left e -> notifyOwner gr $ "Error: getGroupRolesStatus. Please notify the developers.\n" <> T.pack e @@ -867,13 +815,13 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o sendToApprove g' gr (n + 1) _ -> pure () - deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () - deContactRoleChanged g@GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do + deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberId -> GroupMemberRole -> IO () + deContactRoleChanged g@GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} ctId gmId contactRole = do logInfo $ "contact ID " <> tshow ctId <> " role changed in group " <> viewGroupName g <> " to " <> tshow contactRole withGroupReg g "contact role changed" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr g uCtRole = "Your role in the group " <> userGroupRef <> " is changed to " <> ctRole - when (ctId `isOwner` gr) $ + when (isOwnerMember gr gmId ctId) $ case groupRegStatus of GRSSuspendedBadRoles | rStatus == GRSOk -> setGroupStatus notifyAdminUsers env cc groupId GRSActive $ \gr' -> do @@ -922,23 +870,23 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o getOwnerGroupMember groupId gr >>= mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action) - deContactRemovedFromGroup :: ContactId -> GroupInfo -> IO () - deContactRemovedFromGroup ctId g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do + deContactRemovedFromGroup :: ContactId -> GroupMemberId -> GroupInfo -> IO () + deContactRemovedFromGroup ctId gmId g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do let gt = maybe "group" groupTypeStr' pg_ logInfo $ "contact ID " <> tshow ctId <> " removed from group " <> viewGroupName g withGroupReg g "contact removed" $ \gr -> - when (ctId `isOwner` gr) $ + when (isOwnerMember gr gmId ctId) $ setGroupStatus notifyAdminUsers env cc groupId GRSRemoved $ \gr' -> do notifyOwner gr' $ "You are removed from the " <> gt <> " " <> userGroupReference gr' g <> ".\n\nThe " <> gt <> " is no longer listed in the directory." notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " owner is removed)." when (isJust pg_) $ leavePublicGroup g - deContactLeftGroup :: ContactId -> GroupInfo -> IO () - deContactLeftGroup ctId g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do + deContactLeftGroup :: ContactId -> GroupMemberId -> GroupInfo -> IO () + deContactLeftGroup ctId gmId g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do let gt = maybe "group" groupTypeStr' pg_ logInfo $ "contact ID " <> tshow ctId <> " left group " <> viewGroupName g withGroupReg g "contact left" $ \gr -> - when (ctId `isOwner` gr) $ + when (isOwnerMember gr gmId ctId) $ setGroupStatus notifyAdminUsers env cc groupId GRSRemoved $ \gr' -> do notifyOwner gr' $ "You left the " <> gt <> " " <> userGroupReference gr' g <> ".\n\nThe " <> gt <> " is no longer listed in the directory." notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " owner left)." @@ -1094,11 +1042,9 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o \*To register a channel*, use _Share via chat_ to send its link to " <> serviceName <> " bot.\n\n\ - \*To register a group*:\n\ - \1️⃣ *Invite* " + \*To register a group*, *invite* " <> serviceName - <> " bot to your group as *admin* - it will create a link for new members to join.\n\ - \2️⃣ *Add* this link to the group's welcome message.\n\n\ + <> " bot to your group as *admin* - once the group is approved, it will create a link for new members to join.\n\n\ \Once your group or channel *approved*, it can be found here or at [simplex.chat/directory](https://simplex.chat/directory).\n\n\ \_We usually review within a day, except holidays_. [More details](https://simplex.chat/docs/directory.html#adding-groups-to-the-directory)." DCHelp DHSCommands -> @@ -1108,22 +1054,25 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o \/list - list the groups you registered.\n\ \`/role ` - view and set default member role for your group.\n\ \`/filter ` - view and set spam filter settings for group.\n\ - \`/link ` - view and upgrade group link.\n\ + \`/link ` - view group link.\n\ \`/delete :` - remove the group you submitted from directory, with _ID_ and _name_ as shown by /list command.\n\n\ \To search for groups, send the search text." - DCSearchGroup s ft -> - sendFoundListedGroups (STSearch s) Nothing notFound $ \gs n -> - let more = if n > length gs then ", sending top " <> tshow (length gs) else "" - in "Found " <> tshow n <> " group(s)" <> more <> "." + DCSearchGroup s ft -> case ft >>= groupLinkUri of + Just uri -> + getRegisteredGroupByLink uri >>= \case + Just (g, gr, ccLink) + | isAdmin -> sendGroupsInfo ct ciId True ([(g, gr)], 1) + | groupRegStatus gr == GRSActive -> sendFoundGroups "Found group:" [(g, gr, Just ccLink)] 0 + _ + | isAdmin -> sendReply "This link is not registered in the directory" + | otherwise -> sendReply linkNotFound + Nothing -> + sendFoundListedGroups (STSearch s) Nothing "No groups found" $ \gs n -> + let more = if n > length gs then ", sending top " <> tshow (length gs) else "" + in "Found " <> tshow n <> " group(s)" <> more <> "." where - notFound - | hasSimplexGroupLink ft = "No groups found.\nTo register a group or a channel, please use \"Share via chat\" feature." - | otherwise = "No groups found" - hasSimplexGroupLink = \case - Just fts -> any isGroupLink fts - Nothing -> False - isGroupLink (FormattedText (Just SimplexLink {linkType}) _) = linkType == XLGroup || linkType == XLChannel - isGroupLink _ = False + linkNotFound = "No groups found.\nTo register a group or a channel, please use \"Share via chat\" feature." + groupLinkUri fts = listToMaybe [uri | FormattedText (Just SimplexLink {linkType, simplexUri = uri}) _ <- fts, linkType == XLGroup || linkType == XLChannel] DCSearchNext -> atomically (TM.lookup (contactId' ct) searchRequests) >>= \case Just SearchRequest {searchType, searchTime, lastGroup} -> do @@ -1162,7 +1111,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o when (isJust pg_) $ leavePublicGroup g Left e -> sendReply $ "Error deleting " <> gt <> " " <> displayName <> ": " <> T.pack e DCMemberRole gId gName_ mRole_ -> - (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> + (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g gr -> ifPublicGroup g (sendReply "This command is not available for public groups.") $ do let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g case mRole_ of @@ -1174,14 +1123,17 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o initialRole n acceptMemberRole <> ("Send /'role " <> tshow gId <> " " <> textEncode anotherRole <> "' to change it.\n\n") <> onlyViaLink gLink - Left _ -> sendReply $ "Error: failed reading the initial member role for the group " <> n + Left _ -> sendReply $ roleError gr n $ "Error: failed reading the initial member role for the group " <> n Just mRole -> do setGroupLinkRole cc g mRole >>= \case Just gLink -> sendReply $ initialRole n mRole <> "\n" <> onlyViaLink gLink - Nothing -> sendReply $ "Error: the initial member role for the group " <> n <> " was NOT upgated." + Nothing -> sendReply $ roleError gr n $ "Error: the initial member role for the group " <> n <> " was NOT updated." where initialRole n mRole = "The initial member role for the group " <> n <> " is set to *" <> textEncode mRole <> "*\n" onlyViaLink gLink = "*Please note*: it applies only to members joining via this link: " <> groupLinkText gLink + roleError gr n err = case groupRegStatus gr of + GRSActive -> err + _ -> "The group link for " <> n <> " is created when the group is approved." DCGroupFilter gId gName_ acceptance_ -> (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> ifPublicGroup g (sendReply "This command is not available for public groups.") $ do @@ -1215,7 +1167,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o Just PCAll -> "_enabled_" Just PCNoImage -> "_enabled for profiles without image_" DCShowUpgradeGroupLink gId gName_ -> - (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}, localDisplayName = gName} _ -> case pg_ of + (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}, localDisplayName = gName} gr -> case pg_ of Just pg@PublicGroupProfile {groupLink} -> sendReply $ "The link to join the " <> groupTypeStr' pg <> " " <> groupReference' gId gName <> ":\n" <> strEncodeTxt groupLink <> maybe "" (("\nSimpleX name: " <>) . simplexNameStr) (verifiedGroupDomain g) @@ -1223,7 +1175,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o let groupRef = groupReference' gId gName withGroupLinkResult groupRef (sendChatCmd cc $ APIGetGroupLink groupId) $ \GroupLink {connLinkContact = gLink@(CCLink _ sLnk_), acceptMemberRole, shortLinkDataSet, shortLinkLargeDataSet = BoolDef slLargeDataSet} -> do - let shouldBeUpgraded = isNothing sLnk_ || not shortLinkDataSet || not slLargeDataSet + let shouldBeUpgraded = (isNothing sLnk_ || not shortLinkDataSet || not slLargeDataSet) && groupRegStatus gr == GRSActive sendReply $ T.unlines $ [ "The link to join the group " <> groupRef <> ":", @@ -1257,7 +1209,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o a >>= \case Right CRGroupLink {groupLink} -> cb groupLink Left (ChatErrorStore (SEGroupLinkNotFound _)) -> - sendReply $ "The group " <> groupRef <> " has no public link." + sendReply $ "The group " <> groupRef <> " has no public link.\nThe group link is created when the group is approved." Right r -> do ts <- getCurrentTime tz <- getCurrentTimeZone @@ -1287,38 +1239,55 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o sendReply notFound Right (gs, n) -> do let moreGroups = n - length gs - updateSearchRequest searchType $ last gs - sendFoundGroups (replyStr gs n) gs moreGroups + gs' = map (\(g, gr, gLink_) -> (g, gr, (\GroupLink {connLinkContact = cl} -> cl) <$> gLink_)) gs + updateSearchRequest searchType $ last gs' + sendFoundGroups (replyStr gs' n) gs' moreGroups Left e -> sendReply $ "Error: searchListedGroups. Please notify the developers.\n" <> T.pack e allGroupsReply sortName gs n = let more = if n > length gs then ", sending " <> sortName <> " " <> tshow (length gs) else "" in tshow n <> " group(s) listed" <> more <> "." - updateSearchRequest :: SearchType -> (GroupInfo, GroupReg) -> IO () - updateSearchRequest searchType (GroupInfo {groupId}, _) = do + updateSearchRequest :: SearchType -> (GroupInfo, GroupReg, Maybe CreatedLinkContact) -> IO () + updateSearchRequest searchType (GroupInfo {groupId}, _, _) = do searchTime <- getCurrentTime let search = SearchRequest {searchType, searchTime, lastGroup = groupId} atomically $ TM.insert (contactId' ct) search searchRequests + getRegisteredGroupByLink :: AConnectionLink -> IO (Maybe (GroupInfo, GroupReg, CreatedLinkContact)) + getRegisteredGroupByLink uri = + sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget uri)) PRMNever Nothing) >>= \case + Right (CRConnectionPlan _ (ACCL SCMContact ccLink) _ _ (CPGroupLink glp)) -> case glp of + GLPOwnLink g -> groupReg g ccLink + GLPKnown {groupInfo = g} -> groupReg g ccLink + GLPConnectingProhibit (Just g) -> groupReg g ccLink + _ -> pure Nothing + _ -> pure Nothing + where + groupReg :: GroupInfo -> CreatedLinkContact -> IO (Maybe (GroupInfo, GroupReg, CreatedLinkContact)) + groupReg g ccLink = fmap (\gr -> (g, gr, ccLink)) . eitherToMaybe <$> getGroupReg cc (groupId' g) sendFoundGroups reply gs moreGroups = void . forkIO $ sendComposedMessages_ cc (SRDirect $ contactId' ct) msgs where msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0] replyMsg = (Just ciId, MCText reply) - foundGroup (g@GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary}, _) = + foundGroup (g@GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary}, _, cLink_) = let membersStr = "_" <> membersCountStr p groupSummary <> "_" showId = if isAdmin then tshow groupId <> ". " else "" - text = T.unlines $ [showId <> groupInfoText (simplexNameStr <$> verifiedGroupDomain g) p, membersStr] ++ knockingStr memberAdmission + text = T.unlines $ [showId <> groupInfoText (simplexNameStr <$> verifiedGroupDomain g) p] <> foundGroupLinkLine p cLink_ <> [membersStr] <> knockingStr memberAdmission in (Nothing, maybe (MCText text) (\image -> MCImage {text, image}) image_) moreMsg = (Nothing, MCText $ "Send /next for " <> tshow moreGroups <> " more result(s).") - + -- link line for a non-public group in search results, unless its welcome message already contains it + foundGroupLinkLine GroupProfile {displayName = n, description, publicGroup} cLink_ = case (publicGroup, cLink_) of + (Nothing, Just gLink) + | not (maybe False (descriptionContainsLink gLink) description) -> [groupLinkLine n (groupLinkText gLink)] + _ -> [] deAdminCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRAdmin -> IO () deAdminCommand ct ciId cmd | knownCt `elem` adminUsers || knownCt `elem` superUsers = case cmd of DCApproveGroup {groupId, displayName = n, groupApprovalId, promote} -> - withGroupAndReg sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId, promoted} -> + withGroupRegLink sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId, promoted} curLink_ -> case groupRegStatus gr of GRSPendingApproval gaId | gaId == groupApprovalId -> do - let GroupInfo {groupProfile = GroupProfile {publicGroup = pg_}} = g + let GroupInfo {groupProfile = GroupProfile {publicGroup = pg_, description = descr_}} = g isPublicGroup_ = isJust pg_ gt = maybe "group" groupTypeStr' pg_ getDuplicateGroup g >>= \case @@ -1331,28 +1300,37 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o let grPromoted' | promoted || knownCt `elem` superUsers = fromMaybe promoted promote | otherwise = False - setGroupStatusPromo sendReply env cc gr GRSActive grPromoted' $ do - let approved = "The " <> gt <> " " <> userGroupReference' gr n <> " is approved" - let commands - | isPublicGroup_ = "" - | otherwise = - "\n\nSupported commands:\n" - <> ("/'filter " <> tshow ugrId <> "' - to configure anti-spam filter.\n") - <> ("/'role " <> tshow ugrId <> "' - to set default member role.\n") - <> ("/'link " <> tshow ugrId <> "' - to view/upgrade group link.") - notifyOwner gr $ - (approved <> " and listed in directory - please moderate it!\n") - <> "_Please note_: if you change the " <> gt <> " profile it will be hidden from directory until it is re-approved." - <> commands - invited <- - forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do - inviteToOwnersGroup og gr $ \case - Right () -> do - owner <- groupOwnerInfo groupRef $ dbContactId gr - pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName - Left err -> pure err - sendReply $ T.toTitle gt <> " approved" <> (if grPromoted' then " (promoted)" else "") <> "!" <> maybe "" ("\n" <>) invited - notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> maybe "" ("\n" <>) invited + gLink_ <- if isPublicGroup_ then pure (Right Nothing) else approvedGroupLink g curLink_ + case gLink_ of + Left e -> sendReply e + Right gLink' -> + setGroupStatusPromo sendReply env cc gr GRSActive grPromoted' $ do + let approved = "The " <> gt <> " " <> userGroupReference' gr n <> " is approved" + addLink = maybe False (\l -> not $ maybe False (descriptionContainsLink l) descr_) gLink' + commands + | isPublicGroup_ = "" + | otherwise = + "\n\nSupported commands:\n" + <> ("/'filter " <> tshow ugrId <> "' - to configure anti-spam filter.\n") + <> ("/'role " <> tshow ugrId <> "' - to set default member role.\n") + <> ("/'link " <> tshow ugrId <> "' - to view group link.") + notifyOwner gr $ + (approved <> " and listed in directory - please moderate it!\n") + <> ( if addLink + then "To help people join, copy the next message with the group link and add it to the end of the group welcome message. The group will remain listed. Any other change to the group profile hides it from the directory until it is re-approved." + else "_Please note_: if you change the " <> gt <> " profile it will be hidden from directory until it is re-approved." + ) + <> commands + when addLink $ forM_ gLink' $ \l -> notifyOwner gr $ groupLinkLine n (groupLinkText l) + invited <- + forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do + inviteToOwnersGroup og gr $ \case + Right () -> do + owner <- groupOwnerInfo groupRef $ dbContactId gr + pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName + Left err -> pure err + sendReply $ T.toTitle gt <> " approved" <> (if grPromoted' then " (promoted)" else "") <> "!" <> maybe "" ("\n" <>) invited + notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> maybe "" ("\n" <>) invited Right GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin Right GRSContactNotOwner -> replyNotApproved "user is not an owner." Right GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin @@ -1361,9 +1339,24 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o replyNotApproved reason = sendReply $ "Group is not approved: " <> reason serviceNotAdmin = serviceName <> " is not an admin." | otherwise -> sendReply "Incorrect approval code" - _ -> sendReply $ "Error: the group " <> groupRef <> " is not pending approval." + status -> sendReply $ "Error: the group " <> groupRef <> " status is " <> groupRegStatusText status <> ", it is not pending approval." where groupRef = groupReference' groupId n + approvedGroupLink g = \case + Just gLink -> + updateGroupLinkData cc user g gLink >>= \case + Right GroupLink {connLinkContact} -> pure $ Right $ Just connLinkContact + Left e -> pure $ Left $ "Error updating group link data: " <> tshow e + Nothing -> + sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case + Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact}} -> pure $ Right $ Just connLinkContact + Left (ChatError e) -> pure $ Left $ case e of + CEGroupUserRole {} -> "Failed creating group link, as service is no longer an admin." + CEGroupMemberUserRemoved -> "Failed creating group link, as service is removed from the group." + CEGroupNotJoined _ -> unexpectedError "group not joined" + CEGroupMemberNotActive -> unexpectedError "service membership is not active" + _ -> unexpectedError "can't create group link" + _ -> pure $ Left $ unexpectedError "can't create group link" DCRejectGroup _gaId _gName -> pure () DCSuspendGroup groupId gName -> do let groupRef = groupReference' groupId gName @@ -1374,7 +1367,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o notifyOwner gr' $ suspended <> " and hidden from directory. Please contact the administrators." sendReply "Group suspended!" notifyOtherSuperUsers $ suspended <> " by " <> viewName (localDisplayName' ct) - _ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended." + status -> sendReply $ "The group " <> groupRef <> " status is " <> groupRegStatusText status <> ", it can't be suspended." DCResumeGroup groupId gName -> do let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr -> @@ -1384,7 +1377,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o notifyOwner gr' $ groupStr <> " is listed in the directory again!" sendReply "Group listing resumed!" notifyOtherSuperUsers $ groupStr <> " listing resumed by " <> viewName (localDisplayName' ct) - _ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed." + status -> sendReply $ "The group " <> groupRef <> " status is " <> groupRegStatusText status <> ", it can't be resumed." DCListLastGroups count -> listLastGroups cc user count >>= \case Left e -> sendReply $ "Error reading groups: " <> T.pack e @@ -1471,18 +1464,25 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o mkSendReply :: Contact -> ChatItemId -> Text -> IO () mkSendReply ct ciId = sendComposedMessage cc ct (Just ciId) . MCText + withGroupRegLink :: (Text -> IO ()) -> GroupId -> GroupName -> (GroupInfo -> GroupReg -> Maybe GroupLink -> IO ()) -> IO () + withGroupRegLink sendReply gId = withGroupRegLink_ sendReply gId . Just + + withGroupRegLink_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> Maybe GroupLink -> IO ()) -> IO () + withGroupRegLink_ sendReply gId gName_ action = + getGroupAndRegLink cc user gId >>= \case + Left e -> sendReply $ "Group " <> tshow gId <> " error (getGroup): " <> T.pack e + Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr, gLink_) + | maybe False (displayName ==) gName_ -> + action g gr gLink_ + | otherwise -> + sendReply $ "Group ID " <> tshow gId <> " has the display name " <> displayName + withGroupAndReg :: (Text -> IO ()) -> GroupId -> GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () withGroupAndReg sendReply gId = withGroupAndReg_ sendReply gId . Just withGroupAndReg_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () withGroupAndReg_ sendReply gId gName_ action = - getGroupAndReg cc user gId >>= \case - Left e -> sendReply $ "Group " <> tshow gId <> " error (getGroup): " <> T.pack e - Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr) - | maybe False (displayName ==) gName_ -> - action g gr - | otherwise -> - sendReply $ "Group ID " <> tshow gId <> " has the display name " <> displayName + withGroupRegLink_ sendReply gId gName_ $ \g gr _ -> action g gr getOwnersInfo :: [(GroupInfo, GroupReg)] -> IO [((GroupInfo, GroupReg), Maybe (Either String Contact))] getOwnersInfo gs = @@ -1560,6 +1560,9 @@ getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Either String GroupL getGroupLink' cc user gInfo = withDB "getGroupLink" cc $ \db -> withExceptT groupDBError $ getGroupLink db user gInfo +updateGroupLinkData :: ChatController -> User -> GroupInfo -> GroupLink -> IO (Either ChatError GroupLink) +updateGroupLinkData cc user gInfo gLink = runReaderT (runExceptT $ setGroupLinkData NRMBackground user gInfo gLink) cc + setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe CreatedLinkContact) setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) where diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index 94375eb025..ea7f7f5ee0 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -33,7 +33,7 @@ module Directory.Store getAllGroupRegs_, getDuplicateGroupRegs, getGroupReg, - getGroupAndReg, + getGroupAndRegLink, listLastGroups, listPendingGroups, getAllListedGroups, @@ -76,7 +76,8 @@ import Simplex.Chat.Store import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Shared (groupInfoQueryFields, groupInfoQueryFrom) import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol (SimplexDomain) +import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..), SimplexDomain) import Simplex.Messaging.Agent.Store.DB (BoolInt (..), fromTextField_) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding.String @@ -308,11 +309,11 @@ getGroupReg_ db gId = |] (Only gId) -getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) -getGroupAndReg cc user@User {userId, userContactId} gId = - withDB "getGroupAndReg" cc $ \db -> do +getGroupAndRegLink :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg, Maybe GroupLink)) +getGroupAndRegLink cc user@User {userId, userContactId} gId = + withDB "getGroupAndRegLink" cc $ \db -> do currentTs <- liftIO getCurrentTime - ExceptT $ firstRow (toGroupInfoReg currentTs (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ + ExceptT $ firstRow (toGroupInfoRegLink currentTs (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) @@ -335,12 +336,10 @@ getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllList getAllListedGroups_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] getAllListedGroups_ db cxt user@User {userId, userContactId} = do currentTs <- getCurrentTime - DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) - >>= mapM (withGroupLink . toGroupInfoReg currentTs cxt user) - where - withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) + map (toGroupInfoRegLink currentTs cxt user) + <$> DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) -searchListedGroups :: ChatController -> User -> SearchType -> Maybe GroupId -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +searchListedGroups :: ChatController -> User -> SearchType -> Maybe GroupId -> Int -> IO (Either String ([(GroupInfo, GroupReg, Maybe GroupLink)], Int)) searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pageSize = withDB' "searchListedGroups" cc $ \db -> do currentTs <- getCurrentTime @@ -387,7 +386,7 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC " where - groups currentTs = (map (toGroupInfoReg currentTs (storeCxt cc) user) <$>) + groups currentTs = (map (toGroupInfoRegLink currentTs (storeCxt cc) user) <$>) count = maybeFirstRow' 0 fromOnly listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " @@ -434,9 +433,12 @@ listPendingGroups cc user@User {userId, userContactId} count = n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" pure (gs, n) -toGroupInfoReg :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) -toGroupInfoReg currentTs cxt User {userContactId} (groupRow :. grRow) = - (toGroupInfo currentTs cxt userContactId [] groupRow, rowToGroupReg grRow) +toGroupInfoReg :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow :. GroupLinkRow) -> (GroupInfo, GroupReg) +toGroupInfoReg currentTs cxt user row = let (g, gr, _) = toGroupInfoRegLink currentTs cxt user row in (g, gr) + +toGroupInfoRegLink :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow :. GroupLinkRow) -> (GroupInfo, GroupReg, Maybe GroupLink) +toGroupInfoRegLink currentTs cxt User {userContactId} (groupRow :. grRow :. linkRow) = + (toGroupInfo currentTs cxt userContactId [] groupRow, rowToGroupReg grRow, toMaybeGroupLink linkRow) type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) @@ -444,10 +446,30 @@ rowToGroupReg :: GroupRegRow -> GroupReg rowToGroupReg (dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, BI promoted, createdAt) = GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} +type GroupLinkRow = (Maybe Int64, Maybe ConnReqContact, Maybe ShortLinkContact, Maybe BoolInt, Maybe BoolInt, Maybe GroupLinkId, Maybe GroupMemberRole) + +toMaybeGroupLink :: GroupLinkRow -> Maybe GroupLink +toMaybeGroupLink (Just userContactLinkId, Just cReq, shortLink, slDataSet, slLarge, Just groupLinkId, mRole_) = + Just + GroupLink + { userContactLinkId, + connLinkContact = CCLink cReq shortLink, + shortLinkDataSet = boolInt slDataSet, + shortLinkLargeDataSet = BoolDef $ boolInt slLarge, + groupLinkId, + acceptMemberRole = fromMaybe GRMember mRole_ + } + where + boolInt = maybe False (\(BI b) -> b) +toMaybeGroupLink _ = Nothing + +-- group with its registration and its join link (user_contact_links) in one query groupReqQuery :: Query -groupReqQuery = groupInfoQueryFields <> groupRegFields <> groupInfoQueryFrom <> groupRegFromCond +groupReqQuery = groupInfoQueryFields <> groupRegFields <> groupLinkFields <> groupInfoQueryFrom <> groupLinkJoin <> groupRegFromCond where groupRegFields = ", r.group_id, r.user_group_reg_id, r.contact_id, r.owner_member_id, r.group_reg_status, r.group_promoted, r.created_at " + groupLinkFields = ", uc.user_contact_link_id, uc.conn_req_contact, uc.short_link_contact, uc.short_link_data_set, uc.short_link_large_data_set, uc.group_link_id, uc.group_link_member_role " + groupLinkJoin = " LEFT JOIN user_contact_links uc ON uc.group_id = g.group_id AND uc.user_id = g.user_id " groupRegFromCond = " JOIN sx_directory_group_regs r ON r.group_id = g.group_id WHERE g.user_id = ? AND mu.contact_id = ? " instance StrEncoding GroupRegStatus where diff --git a/apps/simplex-support-bot-light/.gitignore b/apps/simplex-support-bot-light/.gitignore new file mode 100644 index 0000000000..b8fdfac347 --- /dev/null +++ b/apps/simplex-support-bot-light/.gitignore @@ -0,0 +1,28 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +build/ +dist/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.pyright_cache/ + +.venv/ +.venv-*/ +venv/ + +config.toml +.env +*.db +*.db-* + +# Tracked as an empty directory: Docker creates a missing bind-mount source as +# root, which the unprivileged bot user cannot write to. +state/* +!state/.gitkeep + +bot-config/*.png +bot-config/*.jpg +bot-config/*.jpeg diff --git a/apps/simplex-support-bot-light/Dockerfile b/apps/simplex-support-bot-light/Dockerfile new file mode 100644 index 0000000000..66427be1a0 --- /dev/null +++ b/apps/simplex-support-bot-light/Dockerfile @@ -0,0 +1,130 @@ +# syntax=docker/dockerfile:1 +# +# Built from the repository root, not this directory: the image is made from the +# Haskell core and the Python library in this tree, neither of them released. +# +# docker compose build # from apps/simplex-support-bot-light +# docker build -f apps/simplex-support-bot-light/Dockerfile . +# +# The first stage compiles libsimplex from src/. That is a full GHC build of +# simplexmq and simplex-chat: hours on a cold cache, and it needs ~15 GB. + +ARG UBUNTU=24.04 +# The released libs are built on 22.04; a lib built here has to load on a runtime +# with the same glibc or newer, not the other way round. +ARG UBUNTU_LIBS=22.04 +ARG GHC=9.6.3 +ARG CABAL=3.10.2.0 + +# --------------------------------------------------------------------------- # +# libsimplex — the cabal invocation of scripts/desktop/build-lib-linux.sh, which +# is what produces the .so the published libs archive is repackaged from. +# --------------------------------------------------------------------------- # +FROM ubuntu:${UBUNTU_LIBS} AS libsimplex + +ARG GHC +ARG CABAL +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential ca-certificates curl git libgmp3-dev libnuma-dev \ + libsqlite3-dev libssl-dev llvm pkg-config zlib1g-dev && \ + rm -rf /var/lib/apt/lists/* + +ENV BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \ + BOOTSTRAP_HASKELL_GHC_VERSION=${GHC} \ + BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL} \ + BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true \ + BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true +RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh +ENV PATH="/root/.ghcup/bin:/root/.cabal/bin:$PATH" +# Explicit, so the cache mount below is where cabal actually keeps its store. +ENV CABAL_DIR=/root/.cabal + +WORKDIR /src +COPY cabal.project simplex-chat.cabal README.md PRIVACY.md ./ +COPY scripts/cabal.project.local.linux ./cabal.project.local +COPY src ./src + +# Cache mounts, not layers: the Haskell store and the build tree survive a +# source change, which is the difference between minutes and hours. The RTS and +# package libraries are copied next to libsimplex.so because its rpath is $ORIGIN. +RUN --mount=type=cache,target=/root/.cabal \ + --mount=type=cache,target=/src/dist-newstyle \ + set -eu; \ + cabal update; \ + cabal build lib:simplex-chat \ + --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' \ + --constraint 'simplexmq +client_library' \ + --constraint 'simplex-chat +client_library'; \ + lib=$(ls -t /src/dist-newstyle/build/*/ghc-${GHC}/simplex-chat-*/build/libHSsimplex-chat-*-inplace-ghc${GHC}.so | head -1); \ + build_dir=$(dirname "$lib"); \ + mv "$lib" "$build_dir/libsimplex.so"; \ + mkdir -p /libs; \ + ldd "$build_dir/libsimplex.so" | grep ghc | cut -d' ' -f 3 | xargs -I {} cp {} /libs/; \ + cp "$build_dir/libsimplex.so" /libs/ + +# --------------------------------------------------------------------------- # +# the bot +# --------------------------------------------------------------------------- # +# libsimplex is a glibc build and will not load on musl, and it is compiled +# against this image's libraries in the stage above. +FROM ubuntu:${UBUNTU} + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl dumb-init libffi8 libgmp10 libnuma1 && \ + rm -rf /var/lib/apt/lists/* + +RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv + +# The ids that must own ./state on the host; a bind mount keeps host ownership. +# Build with your own to avoid needing root to read the bot's state: +# USER_UID=$(id -u) USER_GID=$(id -g) docker compose build +ARG USER_UID=1000 +ARG USER_GID=1000 + +# ubuntu:24.04 ships a default user at 1000, so drop whoever holds the ids. +RUN if existing_user=$(getent passwd ${USER_UID} | cut -d: -f1) && [ -n "${existing_user}" ]; then \ + userdel -r "${existing_user}" 2>/dev/null || userdel "${existing_user}"; \ + fi && \ + if existing_group=$(getent group ${USER_GID} | cut -d: -f1) && [ -n "${existing_group}" ]; then \ + groupdel "${existing_group}" 2>/dev/null || true; \ + fi && \ + groupadd -g ${USER_GID} supportbot && \ + useradd -u ${USER_UID} -g ${USER_GID} -m -d /home/supportbot supportbot + +# Applies only when /data is not bind-mounted; a bind mount keeps the host +# directory's ownership and mode. +RUN mkdir -p /data && chown supportbot:supportbot /data && chmod 0700 /data + +# Read by simplex_chat._native instead of downloading a release, which is what +# makes the bot run against the core built above rather than the published one. +COPY --from=libsimplex /libs /opt/simplex/libs +ENV SIMPLEX_LIBS_DIR=/opt/simplex/libs + +USER supportbot +WORKDIR /home/supportbot + +ENV VIRTUAL_ENV=/home/supportbot/.venv +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +# The library is installed from this tree: the APIs the bot uses +# (install_signal_handlers, sync_profile, api_merge_*_custom_data) are unreleased. +COPY --chown=supportbot:supportbot packages/simplex-chat-python /home/supportbot/simplex-chat-python +RUN uv venv --python 3.12 "$VIRTUAL_ENV" && \ + uv pip install /home/supportbot/simplex-chat-python + +COPY --chown=supportbot:supportbot apps/simplex-support-bot-light /home/supportbot/app +RUN uv pip install -e /home/supportbot/app + +ENV PYTHONUNBUFFERED=1 + +# No HEALTHCHECK: /health is published for an external monitor, and a container +# check would restart a bot whose chat controller is merely slow. + +# Exec form: shell form would run under `sh -c`, which does not forward SIGTERM +# to the bot, so the graceful-stop path in __main__.py would never fire. +ENTRYPOINT ["dumb-init", "--", "support-bot-light", "--config", "/etc/support-bot-light/config.toml"] diff --git a/apps/simplex-support-bot-light/Dockerfile.dockerignore b/apps/simplex-support-bot-light/Dockerfile.dockerignore new file mode 100644 index 0000000000..b27b4a7eb5 --- /dev/null +++ b/apps/simplex-support-bot-light/Dockerfile.dockerignore @@ -0,0 +1,40 @@ +# The build context is the repository root. Exclude everything, then add back +# what the image is built from, one directory level at a time. +* + +!cabal.project +!simplex-chat.cabal +!README.md +!PRIVACY.md +!src + +!scripts +scripts/* +!scripts/cabal.project.local.linux + +!packages +packages/* +!packages/simplex-chat-python + +!apps +apps/* +!apps/simplex-support-bot-light + +# Build output, caches, and anything holding an identity or a secret. +**/__pycache__ +**/*.py[cod] +**/*.egg-info +**/.venv +**/.venv-* +**/.pytest_cache +**/.ruff_cache +**/.pyright_cache +**/.mypy_cache +**/dist +**/*.db +**/*.db-* +apps/simplex-support-bot-light/plans +apps/simplex-support-bot-light/state +apps/simplex-support-bot-light/bot-config +apps/simplex-support-bot-light/config.toml +apps/simplex-support-bot-light/.env diff --git a/apps/simplex-support-bot-light/README.md b/apps/simplex-support-bot-light/README.md new file mode 100644 index 0000000000..058f539d3f --- /dev/null +++ b/apps/simplex-support-bot-light/README.md @@ -0,0 +1,148 @@ +# simplex-support-bot-light + +A [SimpleX Chat](https://simplex.chat) bot that adds a roster of people to +incoming business chats. + +Anyone who connects to the bot's address gets a business chat with a welcome +message, and every active roster member is added to it. People join the roster +themselves, from a command menu in a separate roster group. + +## Docker + +From `apps/simplex-support-bot-light`: + +```bash +cp bot-config/config.toml.example bot-config/config.toml # required; edit before starting +printf 'USER_UID=%s\nUSER_GID=%s\n' "$(id -u)" "$(id -g)" > .env # see Ownership +chmod 0700 state +docker compose up --build -d +docker compose logs -f support-bot-light +``` + +Use the template in `bot-config/`, whose paths are container paths, not the +top-level one. Place the avatar beside it if `bot.image` is set. + +| Path | Mount | Notes | +| --- | --- | --- | +| `./bot-config` | `/etc/support-bot-light` (read-only) | `bot.image` resolves against this directory. | +| `./state` | `/data` | All bot state. `bot.db_prefix` must point here. | + +The monitoring endpoint is published on `127.0.0.1:8080`, and the container +config must set `health.host = "0.0.0.0"`, as the template does. + +Run detached. Under an attached `docker compose up`, Ctrl+C stops the container +but compose re-attaches it; press Ctrl+C twice or use +`--abort-on-container-exit`. + +### State directory + +`./state` holds the bot's identity and address. Deleting it produces a new +address and a new roster group, and every roster member must repeat the +handshake. Back it up. + +It must be owned by the uid the container runs as, set in `.env`. Both ids +default to 1000; root is not supported. `chmod 0700` it on a shared host, since +the databases hold the bot's identity keys. + +## Manual installation + +```bash +uv venv && uv pip install -e ../../packages/simplex-chat-python && uv pip install -e '.[dev]' +cp config.toml.example config.toml +uv run support-bot-light --config config.toml +``` + +The library is installed from this repository, since the APIs the bot uses are +unreleased. `libsimplex` is downloaded on first use unless `SIMPLEX_LIBS_DIR` +points at a local build. + +`--config` defaults to `config.toml` in the working directory. `Ctrl+C` stops +the bot; a second `Ctrl+C` exits immediately. + +## Configuration + +`config.toml.example` is the committed template; `config.toml` is gitignored. + +| Key | Required | Description | +| --- | --- | --- | +| `bot.display_name` | yes | Name shown to anyone who connects. | +| `bot.image` | no | Profile image path (`.png`, `.jpg`, `.jpeg`). Relative paths resolve against the directory containing `config.toml`. The encoded image must not exceed 12500 characters, roughly a 128x128 avatar. | +| `bot.db_prefix` | yes | SQLite path prefix. Creates `_chat.db` and `_agent.db`. Under Docker it must point inside `/data`. | +| `bot.welcome` | yes | Message posted into each new business chat, sent as the address auto-reply. Multi-line TOML strings are supported. | +| `roster.group_name` | yes | Name of the roster group, applied when it is created. | +| `roster.member_role` | no | Role roster members receive in business chats: `observer`, `author`, `member`, `moderator`, `admin` or `owner`. Defaults to `owner`. | +| `health.enabled` | no | Set `false` to switch the monitoring endpoint off. On by default. | +| `health.host` | no | Interface the endpoint binds. Defaults to `127.0.0.1`; `0.0.0.0` under Docker. | +| `health.port` | no | Port for the endpoint. Defaults to `8080`. Setting either key makes a bind failure fatal. | + +Changing `bot.welcome` or `bot.image` applies on the next start. + +The first start logs two links: the business address, for customers, and the +roster group link, for people who should answer. Anyone who joins the roster +group can add themselves to every incoming chat. + +## Monitoring + +The bot serves `GET /health` unless `health.enabled` is `false`: + +| Status | Meaning | +| --- | --- | +| `200 {"status":"ok"}` | The core answered a query against the roster group. | +| `503 {"status":"unavailable"}` | It returned an error, or did not answer within 5 seconds. | + +A bot whose messaging servers are unreachable still answers `200`. + +There is no authentication. Bind it to `127.0.0.1`, or to an interface only the +monitoring system can reach. If `health.host` or `health.port` is set and the +address cannot be bound, the bot exits; otherwise a busy default port only logs +a warning. + +## Commands + +Available in the roster group. + +| Command | Effect | +| --- | --- | +| `/dm` | Join the roster. If the bot has no direct contact, it sends a contact request first; membership becomes active once that request is accepted. | +| `/list` | List active members, members who are no longer reachable, and those pending a contact request. | +| `/leave` | Leave the roster. Chats already joined are unaffected. | +| `/help` | Summarise the above. | + +Leaving the roster group, or being removed from it, also takes a member off the +roster. The bot is the group's only owner, so removing another member requires a +client signed in as the bot. + +## State + +All state is in the databases at `bot.db_prefix`. Roster membership is stored in +each contact's `custom_data`, and the roster group is found by a marker in the +group's `custom_data` rather than by name. + +Startup reconciles what downtime missed: acceptances that arrived while the bot +was stopped, members who left the roster group, and business chats left without +their roster members. + +## Development + +```bash +source .venv/bin/activate +ruff check && ruff format --check src tests && pyright && pytest tests/ -v +``` + +Scope `ruff format` to `src tests`. An unscoped run also reformats Python +fenced inside markdown files. + +## Limitations + +- Joining the roster never grants access to earlier conversations, including + chats a returning customer reopens. +- `bot.display_name` cannot be changed to a name any contact, group or past + customer already holds. The bot logs this and keeps its current name. +- Every active member is added to every incoming chat. There is no routing or + per-customer selection. +- There is no command to remove someone else from the roster, and `/leave` does + not remove anyone from chats they have already joined. + +## License + +[AGPL-3.0](../../LICENSE) diff --git a/apps/simplex-support-bot-light/bot-config/config.toml.example b/apps/simplex-support-bot-light/bot-config/config.toml.example new file mode 100644 index 0000000000..08228a47b3 --- /dev/null +++ b/apps/simplex-support-bot-light/bot-config/config.toml.example @@ -0,0 +1,30 @@ +# Copy to ./bot-config/config.toml. Paths here are container paths; use the +# top-level config.toml.example when running the bot directly on the host. + +[bot] +display_name = "Support" +# Optional. .png, .jpg or .jpeg, resolved against the directory holding this +# file. The encoded data URI must not exceed 12500 characters, roughly a +# 128x128 avatar. +# image = "./avatar.png" +# Must be under /data (bind-mounted from ./state). Creates _chat.db and +# _agent.db, which hold the bot's identity. +db_prefix = "/data/support_bot_light" +welcome = "Hi! Someone from the team will join this chat in a moment." + +[roster] +# Renaming the group later has no effect: it is found by a marker in its custom +# data, not by name. +group_name = "Invite roster" +# One of: observer, author, member, moderator, admin, owner. "relay" is also +# accepted by the core but is an infrastructure role. +member_role = "owner" + +# Monitoring endpoint: GET /health answers 200 while the chat controller +# responds to a command, 503 when it does not. It must bind 0.0.0.0 to be +# reachable through the published port; docker-compose.yml publishes it on the +# host loopback, because the endpoint has no authentication. +[health] +# enabled = false +host = "0.0.0.0" +port = 8080 diff --git a/apps/simplex-support-bot-light/config.toml.example b/apps/simplex-support-bot-light/config.toml.example new file mode 100644 index 0000000000..7c7b3e6942 --- /dev/null +++ b/apps/simplex-support-bot-light/config.toml.example @@ -0,0 +1,25 @@ +[bot] +display_name = "Support" +# Optional. .png, .jpg or .jpeg, resolved against the directory holding this +# file rather than the working directory. The encoded data URI must not exceed +# 12500 characters, roughly a 128x128 avatar. +# image = "./avatar.png" +# Creates _chat.db and _agent.db. +db_prefix = "./support_bot_light" +welcome = "Hi! Someone from the team will join this chat in a moment." + +[roster] +# Renaming the group later has no effect: it is found by a marker in its custom +# data, not by name. +group_name = "Invite roster" +# One of: observer, author, member, moderator, admin, owner. "relay" is also +# accepted by the core but is an infrastructure role. +member_role = "owner" + +# Monitoring endpoint: GET /health answers 200 while the chat controller +# responds to a command, 503 when it does not. On by default, at the values +# below. It has no authentication, so keep it off a public interface. +[health] +# enabled = false +host = "127.0.0.1" +port = 8080 diff --git a/apps/simplex-support-bot-light/docker-compose.yml b/apps/simplex-support-bot-light/docker-compose.yml new file mode 100644 index 0000000000..3928bf201b --- /dev/null +++ b/apps/simplex-support-bot-light/docker-compose.yml @@ -0,0 +1,27 @@ +services: + support-bot-light: + build: + # The repository root: the image is built from the Haskell core and the + # Python library in this tree, neither of them released. + context: ../.. + dockerfile: apps/simplex-support-bot-light/Dockerfile + args: + # Defaults to 1000. Set both to your own ids to own ./state yourself. + USER_UID: ${USER_UID:-1000} + USER_GID: ${USER_GID:-1000} + # Bounded on purpose. A config that will not load is not fixed by retrying, + # and an unbounded policy turns it into a log flood that also makes Ctrl+C + # wait out the grace period. Five is enough to ride out a transient fault. + restart: on-failure:5 + volumes: + # Directory, not a single file: bot.image resolves relative paths against + # the directory holding config.toml. + - ./bot-config:/etc/support-bot-light:ro + # Holds the bot's identity, address, roster group and roster. Deleting it + # means a new address and every roster member redoing the handshake. + - ./state:/data + stop_grace_period: 10s + ports: + # GET /health. Published on the host loopback because the endpoint has no + # authentication; widen it only for a monitoring system that needs it. + - "127.0.0.1:7777:8080" diff --git a/apps/simplex-support-bot-light/pyproject.toml b/apps/simplex-support-bot-light/pyproject.toml new file mode 100644 index 0000000000..5788403b66 --- /dev/null +++ b/apps/simplex-support-bot-light/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "simplex-support-bot-light" +version = "0.1.0" +description = "SimpleX bot that adds a self-service roster to incoming business chats" +readme = "README.md" +license = "AGPL-3.0-only" +requires-python = ">=3.11" +dependencies = ["simplex-chat>=7.1.0b0"] + +[project.optional-dependencies] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "pyright>=1.1.380", "ruff>=0.6"] + +[project.scripts] +support-bot-light = "support_bot_light.__main__:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/support_bot_light"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.pyright] +venvPath = "." +venv = ".venv" +include = ["src/support_bot_light"] +exclude = ["**/__pycache__", "**/.venv*"] diff --git a/apps/simplex-support-bot-light/src/support_bot_light/__init__.py b/apps/simplex-support-bot-light/src/support_bot_light/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/simplex-support-bot-light/src/support_bot_light/__main__.py b/apps/simplex-support-bot-light/src/support_bot_light/__main__.py new file mode 100644 index 0000000000..a7476643a2 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/__main__.py @@ -0,0 +1,211 @@ +"""Entry point: load config, start the bot, wire handlers, serve.""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import stat +import sys +from pathlib import Path + +from simplex_chat import Bot, BotProfile, ChatError, SqliteDb +from simplex_chat.core import ChatInitError +from simplex_chat.types import CEvt + +from . import business, commands, handlers, health, setup +from .config import Config, ConfigError, load_config +from .context import BotContext + +log = logging.getLogger("support_bot_light") + +# storeError tag the core returns when a display name is already in use. +DUPLICATE_NAME = "duplicateName" + +# ChatInitError is raised only while opening the database, which is why it +# belongs here rather than in the per-command guards elsewhere. +STARTUP_ERRORS = (ChatError, ChatInitError) + + +def _register(bot: Bot, ctx: BotContext) -> None: + """Register handlers. Commands are scoped to the roster group, so the same + keyword typed in a business chat falls through and is ignored.""" + group_id = ctx.roster_group_id + + @bot.on_command(commands.DM, group_id=group_id) + async def _dm(msg, _cmd): + await handlers.dm(ctx, msg) + + @bot.on_command(commands.LIST, group_id=group_id) + async def _list(msg, _cmd): + await handlers.list_roster(ctx, msg) + + @bot.on_command(commands.LEAVE, group_id=group_id) + async def _leave(msg, _cmd): + await handlers.leave(ctx, msg) + + @bot.on_command(commands.HELP, group_id=group_id) + async def _help(msg, _cmd): + await handlers.help_cmd(ctx, msg) + + @bot.on_event("acceptingBusinessRequest") + async def _business(evt: CEvt.AcceptingBusinessRequest): + await business.on_business_request(ctx, evt) + + @bot.on_event("contactConnected") + async def _connected(evt: CEvt.ContactConnected): + await handlers.contact_ready(ctx, evt["contact"]["contactId"]) + + @bot.on_event("contactSndReady") + async def _snd_ready(evt: CEvt.ContactSndReady): + await handlers.contact_ready(ctx, evt["contact"]["contactId"]) + + @bot.on_event("deletedMember") + async def _deleted_member(evt: CEvt.DeletedMember): + await handlers.member_gone(ctx, evt["groupInfo"]["groupId"], evt["deletedMember"]) + + @bot.on_event("leftMember") + async def _left_member(evt: CEvt.LeftMember): + await handlers.member_gone(ctx, evt["groupInfo"]["groupId"], evt["member"]) + + +def startup_error(e: Exception) -> str: + """What the operator can act on, from an exception that names only a tag. + + The core reports a display name already taken by a contact or group as a + bare `errorStore`, and the detail the bot needs is in the store error. + """ + if getattr(e, "store_error_type", None) == DUPLICATE_NAME: + return ( + "bot.display_name is already taken in this database by a contact, a " + "group or a past customer; the core keeps every display name unique. " + "Choose another name." + ) + command_error = getattr(e, "command_error", None) + if command_error is not None: + return command_error + chat_error = getattr(e, "chat_error", None) + return f"{e} {chat_error}" if chat_error else str(e) + + +def bot_profile(config: Config) -> BotProfile: + return BotProfile(display_name=config.display_name, image=config.image) + + +def build_bot(config: Config) -> Bot: + """The bot's identity and address settings. + + business_address is what makes a connection open a group the roster can be + added to; without it every customer would get a plain direct chat and the + bot would have nothing to do. + + The profile is applied after the client starts, not by the startup sync, so + that a name the core refuses does not stop the bot. See `_apply_profile`. + """ + return Bot( + profile=bot_profile(config), + db=SqliteDb(file_prefix=config.db_prefix), + welcome=config.welcome, + business_address=True, + auto_accept=True, + update_profile=False, + # The library logs peer display names verbatim; the bot sanitises every + # name it renders itself, and this is the one path that bypasses it. + log_contacts=False, + ) + + +async def _run(config: Config) -> None: + bot = build_bot(config) + # Before the client starts: a signal during migrations would otherwise hit + # the default disposition and kill the process mid-write. + bot.install_signal_handlers() + await _serve(config, bot) + + +async def _apply_profile(bot: Bot) -> None: + """Apply the configured profile once the database can be reached. + + The core refuses a display name another contact or group holds, and the + profile update broadcasts to every contact, so it is the startup step most + likely to fail. Answering customers matters more than a name or an avatar. + """ + try: + await bot.sync_profile() + except ChatError as e: + log.error("%s", startup_error(e)) + log.warning("Serving without applying the profile change.") + + +async def _serve(config: Config, bot: Bot) -> None: + # Not bot.run(): handlers are scoped with group_id=, which is unknown until + # the roster group is resolved after start. + async with bot: + user = await bot.api.api_get_active_user() + if user is None: + raise RuntimeError("no active user after start") + user_id = user["userId"] + await _apply_profile(bot) + roster_group_id = await setup.ensure_roster_group(bot.api, user_id, config) + ctx = BotContext( + api=bot.api, + user_id=user_id, + roster_group_id=roster_group_id, + config=config, + ) + _register(bot, ctx) + groups = await bot.api.api_list_groups(user_id) + await handlers.reconcile_roster(ctx, groups) + await business.reconcile_chats(ctx, groups) + + if bot.stop_requested: + # A signal arrived during startup; unwind rather than begin serving. + log.info("stopped during startup") + return + + server = await health.serve(ctx, config.health) if config.health else None + try: + await bot.serve_forever() + finally: + if server is not None: + server.close() + await server.wait_closed() + + +def main() -> int: + parser = argparse.ArgumentParser(prog="support-bot-light") + parser.add_argument("--config", type=Path, default=Path("config.toml")) + args = parser.parse_args() + + if not logging.getLogger().handlers: + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s" + ) + # The core creates its databases with the process umask, and they hold the + # bot's identity keys. Docker mounts a 0700 directory; a manual install + # would otherwise put them in the working directory at 0644. + os.umask(stat.S_IRWXG | stat.S_IRWXO) + + try: + config = load_config(args.config) + except ConfigError as e: + log.error("%s", e) + return 2 + try: + asyncio.run(_run(config)) + except ConfigError as e: + # Raised past load_config only by the health endpoint, which cannot know + # its port is taken until it binds. + log.error("%s", e) + return 2 + except STARTUP_ERRORS as e: + # Startup rejections the core only reports at first use, such as a + # database it will not open. + log.error("%s", startup_error(e)) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/business.py b/apps/simplex-support-bot-light/src/support_bot_light/business.py new file mode 100644 index 0000000000..9e95c0cff2 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/business.py @@ -0,0 +1,180 @@ +"""Incoming business chats: add the roster, log the invite.""" + +from __future__ import annotations + +import logging + +from simplex_chat import ChatError +from simplex_chat.types import CEvt, T + +from . import messages, roster +from .context import BotContext +from .text import safe_name + +log = logging.getLogger(__name__) + +# Written to a business chat's custom data once its roster pass has run. +ROSTERED = "rostered" + + +def _rostered(group: T.GroupInfo) -> bool: + mark = (group.get("customData") or {}).get(roster.NAMESPACE) + return isinstance(mark, dict) and mark.get(ROSTERED) is True + + +async def _mark_rostered(ctx: BotContext, group: T.GroupInfo) -> None: + """Record that this chat has had its roster pass, preserving other keys. + + Without this, startup repair cannot tell a chat a crash left half-finished + from one that was completed before the roster changed — and would add + people who joined the roster later to every conversation the bot has ever + handled. + """ + existing = (group.get("customData") or {}).get(roster.NAMESPACE) + mark: dict[str, object] = dict(existing) if isinstance(existing, dict) else {} + mark[ROSTERED] = True + await ctx.api.api_merge_group_custom_data(group, roster.NAMESPACE, mark) + + +async def _mark(ctx: BotContext, group: T.GroupInfo) -> None: + """Mark the chat, containing the failure: the next start re-derives it.""" + try: + await _mark_rostered(ctx, group) + except ChatError: + log.warning("could not mark business chat %s as rostered", group["groupId"], exc_info=True) + + +async def _add_missing( + ctx: BotContext, group_id: int, entries: list[roster.RosterEntry] +) -> tuple[list[str], list[str]]: + """Add every entry not already in the group. Returns (added, failed).""" + present = await roster.contact_ids_in_group(ctx.api, group_id) + + added: list[str] = [] + failed: list[str] = [] + for entry in entries: + if entry.contact_id in present: + continue + try: + # Final role in one call: promoting a pending invitee re-sends the + # invitation. + await ctx.api.api_add_member(group_id, entry.contact_id, ctx.config.member_role) + added.append(entry.name) + except ChatError: + log.exception("failed adding %s to business chat %s", entry.name, group_id) + failed.append(entry.name) + return added, failed + + +async def _roster_for_chats(ctx: BotContext) -> list[roster.RosterEntry]: + """Active roster members who are still in the roster group. + + Revocation is driven by an event, and the core delivers a queued business + request before a queued departure just as readily as after it, so the mark + alone would let somebody who has left read a conversation started after they + went. Membership of the roster group is the access-control boundary, so it + is what decides: read for each incoming chat, and once per startup pass. + """ + entries = await roster.active(ctx.api, ctx.user_id) + if not entries: + return [] + present = await roster.contact_ids_in_group(ctx.api, ctx.roster_group_id) + return [e for e in entries if e.contact_id in present] + + +async def reconcile_chats(ctx: BotContext, groups: list[T.GroupInfo] | None = None) -> None: + """Add active roster members to business chats that are missing them. + + Adding members is the only step with no second chance: it is driven by an + event delivered once, so a crash part-way through the loop would leave that + customer permanently short of the roster. + + Only chats whose roster pass never completed are touched. A chat that was + finished before someone joined the roster is left alone: `/dm` promises to + add you to chats "from now on", and back-filling would hand every past + customer conversation to whoever joined the roster most recently. + """ + try: + entries = await _roster_for_chats(ctx) + if groups is None: + groups = await ctx.api.api_list_groups(ctx.user_id) + except ChatError: + log.warning("could not reconcile business chats on startup", exc_info=True) + return + + repaired = 0 + for group in groups: + if "businessChat" not in group or not roster.in_group(group["membership"]): + continue + if _rostered(group): + continue + group_id = group["groupId"] + try: + added, failed = ([], []) if not entries else await _add_missing(ctx, group_id, entries) + except ChatError: + log.warning("could not reconcile business chat %s", group_id, exc_info=True) + continue + repaired += 1 + log.info("finished the roster pass for business chat %s on startup", group_id) + # Reported even when nobody had to be added: the chat was left unmarked, + # so the crash took the roster group's record of that customer with it. + await ctx.post_to_roster(_report(_customer_of(group), entries, added, failed)) + if added or not failed: + # Left unmarked means the repair did not finish; the queued event + # should be allowed to retry it in this session. + ctx.repaired.add(group_id) + # Marked even with an empty roster: the pass has run for this chat, + # and leaving it unmarked would back-fill whoever joins later. + await _mark(ctx, group) + if repaired: + log.info("finished %d business chats left incomplete by a restart", repaired) + + +def _report( + customer: str, entries: list[roster.RosterEntry], added: list[str], failed: list[str] +) -> str: + """The roster group's record of one business chat.""" + if not entries: + return messages.EMPTY_ROSTER_LOG.format(customer=customer) + if not added and not failed: + return messages.NOBODY_NEW_LOG.format(customer=customer) + return messages.invite_log(customer, added, failed) + + +def _customer_of(group: T.GroupInfo) -> str: + return safe_name((group.get("groupProfile") or {}).get("displayName") or "") + + +async def on_business_request(ctx: BotContext, evt: CEvt.AcceptingBusinessRequest) -> None: + """Add every active roster member to a new business chat, then log it.""" + group = evt["groupInfo"] + group_id = group["groupId"] + if group_id in ctx.repaired: + # Startup repair already ran for this chat and reported it; the queued + # event would otherwise log the same customer a second time. + ctx.repaired.discard(group_id) + return + # For a business chat the group's display name is the customer's own + # profile string, which the core does not sanitise. + customer = _customer_of(group) + + # A failure before anything is added must still reach the roster group, + # which is the operator's only visibility. + try: + entries = await _roster_for_chats(ctx) + added, failed = ([], []) if not entries else await _add_missing(ctx, group_id, entries) + except ChatError: + log.exception("failed reading roster for business chat %s", group_id) + await ctx.post_to_roster(messages.BUSINESS_FAILED_LOG.format(customer=customer)) + return + + await ctx.post_to_roster(_report(customer, entries, added, failed)) + + # Marked even when the line above failed to send. The marker records that + # the pass ran, and an unmarked chat is repaired by every later start with + # the roster of the day — so withholding it to preserve one log line would + # hand a past customer's conversation to whoever joins the roster next. + # Not marked when every add failed and none succeeded: that chat has no + # roster at all, so the next start should retry rather than skip it. + if added or not failed: + await _mark(ctx, group) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/commands.py b/apps/simplex-support-bot-light/src/support_bot_light/commands.py new file mode 100644 index 0000000000..c081a40e37 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/commands.py @@ -0,0 +1,37 @@ +"""The bot's command menu: declarations plus conversion to group-preference wire dicts.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from simplex_chat import BotCommand +from simplex_chat.types import T + +DM = "dm" +LIST = "list" +LEAVE = "leave" +HELP = "help" + +COMMANDS: tuple[BotCommand, ...] = ( + BotCommand(keyword=DM, label="Add me to incoming chats"), + BotCommand(keyword=LIST, label="Who gets invited"), + BotCommand(keyword=LEAVE, label="Stop adding me"), + BotCommand(keyword=HELP, label="How this works"), +) + + +def to_wire(commands: Sequence[BotCommand]) -> list[T.ChatBotCommand]: + """Convert declarations to `groupPreferences.commands` entries.""" + wire: list[T.ChatBotCommand] = [] + for c in commands: + entry: T.ChatBotCommand_command = { + "type": "command", + "keyword": c.keyword, + "label": c.label, + } + # Omitted rather than empty: the client sends on tap for Nothing, but + # pastes for Just "". + if c.params is not None: + entry["params"] = c.params + wire.append(entry) + return wire diff --git a/apps/simplex-support-bot-light/src/support_bot_light/config.py b/apps/simplex-support-bot-light/src/support_bot_light/config.py new file mode 100644 index 0000000000..79dd3bfefb --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/config.py @@ -0,0 +1,207 @@ +"""Load and validate `config.toml`.""" + +from __future__ import annotations + +import base64 +import stat +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any, get_args + +from simplex_chat.types import T + +DEFAULT_MEMBER_ROLE: T.GroupMemberRole = "owner" +MEMBER_ROLES: tuple[str, ...] = get_args(T.GroupMemberRole) + +# maxProfileImageSize in src/Simplex/Chat/Library/Commands.hs. Measured against +# the whole data URI, not the raw file. +MAX_PROFILE_IMAGE_SIZE = 12500 + +# Raw bytes that still fit once base64 and the "data:image/png;base64," prefix +# are added. Checked before the file is read. +MAX_IMAGE_BYTES = (MAX_PROFILE_IMAGE_SIZE - 22) // 4 * 3 + +# The welcome is sent as a chat message, so it is held below the core's wire +# limit (maxEncodedMsgLength) with room to spare rather than at it. +MAX_WELCOME_BYTES = 12000 + +# On unless switched off, so a deployment is monitorable without being +# configured for it. Loopback, because the endpoint has no authentication. +DEFAULT_HEALTH_HOST = "127.0.0.1" +DEFAULT_HEALTH_PORT = 8080 +MAX_PORT = 65535 + +# Tag in the data:image/;base64, prefix. The core accepts any "data:" string; +# the clients strip only the png and jpg prefixes, so jpeg renders as nothing. +IMAGE_EXTENSION_TAGS = {".png": "png", ".jpg": "jpg", ".jpeg": "jpg"} + + +class ConfigError(ValueError): + """`config.toml` is missing, malformed, or has an invalid value.""" + + +@dataclass(frozen=True, slots=True) +class Health: + """Where the monitoring endpoint listens.""" + + host: str + port: int + # True when the config names the port. A port the operator chose has to + # work; the default must never be what keeps the bot from starting. + configured: bool = False + + +@dataclass(frozen=True, slots=True) +class Config: + """Validated settings loaded from `config.toml`.""" + + display_name: str + db_prefix: str + welcome: str + group_name: str + member_role: T.GroupMemberRole + image: str | None = None + health: Health | None = None + + +def load_config(path: Path) -> Config: + """Read and validate `config.toml` at `path`, raising `ConfigError` on any problem.""" + try: + raw = tomllib.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as e: + # Names, not a command: under Docker this directory is mounted + # read-only, so the copy is made on the host. + template = path.with_name(path.name + ".example") + hint = f" — copy {template.name} to {path.name} and edit it" if template.exists() else "" + raise ConfigError(f"config file not found: {path}{hint}") from e + except tomllib.TOMLDecodeError as e: + raise ConfigError(f"invalid TOML in {path}: {e}") from e + except UnicodeDecodeError as e: + raise ConfigError(f"config file is not UTF-8: {path}") from e + except OSError as e: + raise ConfigError(f"config file could not be read ({path}): {e}") from e + + bot = _section(raw, "bot") + roster = _section(raw, "roster") + role = roster.get("member_role", DEFAULT_MEMBER_ROLE) + if role not in MEMBER_ROLES: + raise ConfigError( + f"roster.member_role must be one of {', '.join(MEMBER_ROLES)}, got {role!r}" + ) + return Config( + display_name=_text(bot, "bot", "display_name"), + db_prefix=_text(bot, "bot", "db_prefix"), + welcome=_bounded_text(bot, "bot", "welcome", MAX_WELCOME_BYTES), + group_name=_text(roster, "roster", "group_name"), + member_role=role, + image=_image(bot, path.parent), + health=_health(raw), + ) + + +def _health(raw: dict[str, Any]) -> Health | None: + """Where the endpoint listens, or None when `health.enabled` switches it off.""" + health = raw.get("health", {}) + if not isinstance(health, dict): + raise ConfigError("[health] must be a section") + enabled = health.get("enabled", True) + if not isinstance(enabled, bool): + raise ConfigError(f"health.enabled must be true or false, got {enabled!r}") + if not enabled: + return None + port = health.get("port", DEFAULT_HEALTH_PORT) + # bool is an int, and TOML has booleans. + if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= MAX_PORT: + raise ConfigError(f"health.port must be an integer between 1 and {MAX_PORT}, got {port!r}") + host = health.get("host", DEFAULT_HEALTH_HOST) + if not isinstance(host, str) or not host.strip(): + raise ConfigError("health.host must be a non-empty string") + # Either key means the operator chose where it listens, and a bind failure + # there is a misconfiguration rather than a coincidence. + return Health(host=host, port=port, configured=bool({"host", "port"} & health.keys())) + + +def _section(raw: dict[str, Any], name: str) -> dict[str, Any]: + section = raw.get(name) + if not isinstance(section, dict): + raise ConfigError(f"missing [{name}] section") + return section + + +def _text(section: dict[str, Any], section_name: str, key: str) -> str: + value = section.get(key) + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"{section_name}.{key} must be a non-empty string") + return value + + +def _bounded_text(section: dict[str, Any], section_name: str, key: str, max_bytes: int) -> str: + value = _text(section, section_name, key) + if len(value.encode()) > max_bytes: + raise ConfigError( + f"{section_name}.{key} is too long: {len(value.encode())} bytes exceeds " + f"the {max_bytes} the core will send; shorten it" + ) + return value + + +def _image(bot: dict[str, Any], config_dir: Path) -> str | None: + """Encode `bot.image` (a file path) as a profile-image data URI, or `None` + if the key is absent. Relative paths resolve against `config_dir` — the + directory containing `config.toml` — not the process's working directory. + """ + if "image" not in bot: + return None + value = _text(bot, "bot", "image") + + image_path = Path(value) + if not image_path.is_absolute(): + image_path = config_dir / image_path + + extension = image_path.suffix.lower() + tag = IMAGE_EXTENSION_TAGS.get(extension) + if tag is None: + supported = ", ".join(sorted(IMAGE_EXTENSION_TAGS)) + raise ConfigError( + f"bot.image has unsupported extension {extension!r} ({image_path}); " + f"supported extensions: {supported}" + ) + + # Inspect before reading: a FIFO would block startup indefinitely and a + # character device such as /dev/zero would exhaust memory. + try: + info = image_path.stat() + except FileNotFoundError as e: + raise ConfigError(f"bot.image file not found: {image_path}") from e + except OSError as e: + raise ConfigError(f"bot.image could not be read ({image_path}): {e}") from e + + if not stat.S_ISREG(info.st_mode): + raise ConfigError(f"bot.image is not a regular file: {image_path}") + if info.st_size > MAX_IMAGE_BYTES: + raise ConfigError( + f"bot.image is too large: {info.st_size} bytes exceeds the {MAX_IMAGE_BYTES} " + f"a {MAX_PROFILE_IMAGE_SIZE}-character data URI can hold; shrink the image " + "(a 128x128 avatar) and try again" + ) + + try: + data = image_path.read_bytes() + except OSError as e: + raise ConfigError(f"bot.image could not be read ({image_path}): {e}") from e + + # The core rejects an empty image file rather than broadcasting a profile + # with an undecodable data URI. + if not data: + raise ConfigError(f"bot.image file is empty: {image_path}") + + encoded = base64.b64encode(data).decode("ascii") + data_uri = f"data:image/{tag};base64,{encoded}" + if len(data_uri) > MAX_PROFILE_IMAGE_SIZE: + raise ConfigError( + f"bot.image is too large: encoded size {len(data_uri)} exceeds the " + f"{MAX_PROFILE_IMAGE_SIZE}-character limit the core enforces on profile " + "images; shrink the image (e.g. to a 96x96 or 128x128 avatar) and try again" + ) + return data_uri diff --git a/apps/simplex-support-bot-light/src/support_bot_light/context.py b/apps/simplex-support-bot-light/src/support_bot_light/context.py new file mode 100644 index 0000000000..d168763557 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/context.py @@ -0,0 +1,36 @@ +"""Everything the handlers need, resolved once at startup.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from simplex_chat import ChatApi, ChatError + +from .config import Config + +log = logging.getLogger(__name__) + + +@dataclass(slots=True) +class BotContext: + """API handle plus the ids and config resolved during startup.""" + + api: ChatApi + user_id: int + roster_group_id: int + config: Config + # Business chats repaired by the startup pass, so the queued event for the + # same chat does not report the customer a second time. + repaired: set[int] = field(default_factory=set) + + async def post_to_roster(self, text: str) -> None: + """Send a message to the roster group. + + Never raises: this is the operator's visibility channel, and a failure + to report an event must not also discard the event that caused it. + """ + try: + await self.api.api_send_text_message(["group", self.roster_group_id], text) + except ChatError: + log.warning("could not post to the roster group: %s", text, exc_info=True) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/handlers.py b/apps/simplex-support-bot-light/src/support_bot_light/handlers.py new file mode 100644 index 0000000000..6870c57126 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/handlers.py @@ -0,0 +1,300 @@ +"""Command, connection and membership handlers, plus the roster catch-up pass.""" + +from __future__ import annotations + +import functools +import logging +from collections.abc import Awaitable, Callable + +from simplex_chat import ChatError, Message +from simplex_chat.types import T + +from . import messages, roster, setup +from .context import BotContext +from .text import safe_name + +log = logging.getLogger(__name__) + + +def _reply_on_error(fn: Callable[[BotContext, Message], Awaitable[None]]): + """Turn a failed command into a visible reply instead of silence.""" + + @functools.wraps(fn) + async def wrapper(ctx: BotContext, msg: Message) -> None: + try: + await fn(ctx, msg) + except ChatError: + log.exception("%s failed", fn.__name__) + await msg.reply(messages.COMMAND_FAILED) + + return wrapper + + +async def _contact_of(ctx: BotContext, member: T.GroupMember) -> T.Contact | None: + """The sender's direct contact, resolved against current state. + + `memberContactId` in the message payload is a snapshot taken when the core + built the chat item. `api_create_member_contact` sets that column, so two + commands sent in quick succession both carry the pre-`/dm` value of `None` + and would otherwise be treated as having no contact at all. + """ + contact_id = member.get("memberContactId") + if contact_id is None: + for m in await ctx.api.api_list_members(ctx.roster_group_id): + if m["groupMemberId"] == member["groupMemberId"]: + contact_id = m.get("memberContactId") + break + if contact_id is None: + return None + return await roster.find_contact(ctx.api, ctx.user_id, contact_id) + + +def group_sender(msg: Message) -> T.GroupMember | None: + """The member who sent a group message, or None if it isn't a group receive.""" + chat_dir = msg.chat_item["chatItem"]["chatDir"] + if chat_dir.get("type") != "groupRcv": + return None + return chat_dir.get("groupMember") + + +@_reply_on_error +async def dm(ctx: BotContext, msg: Message) -> None: + """Put the sender on the roster, sending a contact request first if needed.""" + member = group_sender(msg) + if member is None: + return + + contact = await _contact_of(ctx, member) + + if contact is not None: + # api_create_member_contact sets memberContactId before the person has + # accepted, so an existing contact is not necessarily usable. + entry = roster.entry_of(contact) + + if roster.contact_usable(contact): + if entry is not None and entry.state == roster.ACTIVE: + await msg.reply(messages.ALREADY_ACTIVE) + return + since = entry.since if entry else roster.utc_now() + await roster.mark(ctx.api, contact, roster.ACTIVE, since) + await msg.reply(messages.ADDED) + # Every other route to active announces; without this the operator's + # log misses arrivals that took the fast path. + name = roster.contact_name(contact) + await ctx.post_to_roster(messages.NOW_ACTIVE.format(name=name)) + return + + if contact.get("contactGroupMemberId") is None: + if roster.accept_started(contact): + # Already accepted; the connection is still completing. Marked + # here too: contact_ready promotes a pending mark and does + # nothing without one, so ACCEPTING would promise a roster place + # that never arrives. + since = entry.since if entry else roster.utc_now() + await roster.mark(ctx.api, contact, roster.PENDING, since) + await msg.reply(messages.ACCEPTING) + return + + if roster.awaiting_accept(contact): + # They connected to us from the group rather than accepting our + # request. Accept it and mark them pending; contactConnected + # then promotes them exactly as it would the other way round. + await ctx.api.api_accept_member_contact(contact["contactId"]) + since = entry.since if entry else roster.utc_now() + await roster.mark(ctx.api, contact, roster.PENDING, since) + await msg.reply(messages.ACCEPTING) + return + + if roster.connecting(contact): + # The core clears contactGroupMemberId when the peer accepts, + # well before the connection reports ready, so this shape is + # also a handshake in progress. Reporting it as gone would send + # the member to CONNECTION_LOST's advice, and connecting + # directly there tears down the connection that was completing. + since = entry.since if entry else roster.utc_now() + await roster.mark(ctx.api, contact, roster.PENDING, since) + await msg.reply(messages.CONNECTING) + return + + # The core clears this once a member contact has connected, and + # api_send_member_contact_invitation requires it, so the handshake + # cannot be re-driven from this side. The mark is left alone: an + # active one renders under "Not reachable", which is the truth. + await msg.reply(messages.CONNECTION_LOST) + return + + # Reaching here means contactGroupMemberId is still set, which the core + # clears on connect: the person never completed the handshake, so an + # active mark is stale. + if entry is None or entry.state != roster.PENDING: + since = entry.since if entry else roster.utc_now() + await roster.mark(ctx.api, contact, roster.PENDING, since) + + if contact.get("contactGrpInvSent"): + # The core rejects a second invitation; the person has simply not + # accepted the first one yet. + await msg.reply(messages.STILL_PENDING) + return + + # First send failed. api_create_member_contact would raise "member + # contact already exists", so resend on the existing contact. + try: + await ctx.api.api_send_member_contact_invitation( + contact["contactId"], messages.INVITATION_TEXT + ) + except ChatError: + log.warning("invitation resend to contact %s failed", contact["contactId"]) + await msg.reply(messages.INVITATION_FAILED) + return + await msg.reply(messages.INVITATION_SENT) + return + + contact = await ctx.api.api_create_member_contact(ctx.roster_group_id, member["groupMemberId"]) + await roster.mark(ctx.api, contact, roster.PENDING, roster.utc_now()) + new_contact_id = contact["contactId"] + try: + await ctx.api.api_send_member_contact_invitation(new_contact_id, messages.INVITATION_TEXT) + except ChatError: + log.warning("invitation to contact %s failed to send", new_contact_id) + await msg.reply(messages.INVITATION_FAILED) + return + await msg.reply(messages.INVITATION_SENT) + + +async def contact_ready(ctx: BotContext, contact_id: int) -> None: + """Promote a pending contact once its connection is usable. + + Shared by contactConnected and contactSndReady. Re-reads the contact rather + than trusting the event payload. + """ + try: + contact = await roster.find_contact(ctx.api, ctx.user_id, contact_id) + if contact is None: + return + entry = roster.entry_of(contact) + if entry is None or entry.state != roster.PENDING: + return + if not entry.reachable: + # The event says the connection is up, but the record is what + # `active()` will consult, so promote only on what it will see. + return + await roster.mark(ctx.api, contact, roster.ACTIVE, entry.since) + except ChatError: + # Nobody is waiting on a reply here, so without this the failure is a + # bare traceback from the library and the person is stranded pending. + log.warning("could not promote contact %s", contact_id, exc_info=True) + return + await ctx.post_to_roster(messages.NOW_ACTIVE.format(name=entry.name)) + + +async def reconcile_roster(ctx: BotContext, groups: list[T.GroupInfo] | None = None) -> None: + """Catch up on what happened while the bot was stopped. + + Both events this compensates for are delivered once and never replayed: an + acceptance (`contactConnected`) leaves someone stuck pending, and a removal + from the roster group leaves someone on the roster who should not be. + + `groups` is passed in by startup so the two passes share one listing, which + is the largest thing startup reads and grows with every customer ever seen. + """ + try: + present = await roster.contact_ids_in_group(ctx.api, ctx.roster_group_id) + contacts = await ctx.api.api_list_contacts(ctx.user_id) + if groups is None: + groups = await ctx.api.api_list_groups(ctx.user_id) + except ChatError: + # Startup must not fail because the catch-up pass could not run. + log.warning("could not reconcile the roster on startup", exc_info=True) + return + + # Revocation deletes the bot's only durable state, so it runs only when the + # roster group is unambiguous. An empty member list is deliberately NOT a + # reason to skip: the last member leaving is when revoking matters most. + marked = sum(1 for g in groups if setup.is_roster_group(g)) + revoke = marked == 1 + if not revoke: + log.warning("%d groups carry the roster marker; skipping revocation", marked) + + for contact in contacts: + entry = roster.entry_of(contact) + if entry is None: + continue + try: + if revoke and entry.contact_id not in present: + await roster.unmark(ctx.api, contact) + log.info("removed %s from the roster: no longer in the roster group", entry.name) + await ctx.post_to_roster(messages.REMOVED_FROM_GROUP.format(name=entry.name)) + elif entry.state == roster.PENDING and entry.reachable: + await roster.mark(ctx.api, contact, roster.ACTIVE, entry.since) + log.info("promoted %s on startup: their connection is ready", entry.name) + await ctx.post_to_roster(messages.NOW_ACTIVE.format(name=entry.name)) + except ChatError: + # One bad contact must not abandon the rest of the pass. + log.warning("could not reconcile contact %s", entry.contact_id, exc_info=True) + + +def _member_name(member: T.GroupMember) -> str: + return member.get("localDisplayName") or (member.get("memberProfile") or {}).get( + "displayName", "" + ) + + +async def member_gone(ctx: BotContext, group_id: int, member: T.GroupMember) -> None: + """Take someone off the roster when they leave or are removed from the group. + + Membership of the roster group is the access-control boundary, so it has to + be revocable: without this, someone removed from the group keeps being added + to every business chat and cannot even run `/leave` to stop it. + """ + if group_id != ctx.roster_group_id: + return + contact_id = member.get("memberContactId") + if contact_id is None: + return + try: + contact = await roster.find_contact(ctx.api, ctx.user_id, contact_id) + if contact is None: + return + entry = roster.entry_of(contact) + if entry is None: + return + await roster.unmark(ctx.api, contact) + except ChatError: + # The only failure in the bot that the roster group would not hear + # about, and it is the one on the access-control path. Access is not at + # risk — every add re-reads roster group membership — but the operator + # is owed the mark still being there until the next start repairs it. + log.warning("could not take contact %s off the roster", contact_id, exc_info=True) + await ctx.post_to_roster( + messages.REVOKE_FAILED.format(name=safe_name(_member_name(member))) + ) + return + log.info("removed %s from the roster: no longer in the roster group", entry.name) + await ctx.post_to_roster(messages.REMOVED_FROM_GROUP.format(name=entry.name)) + + +@_reply_on_error +async def list_roster(ctx: BotContext, msg: Message) -> None: + """Reply with the roster, active and pending.""" + entries = await roster.load(ctx.api, ctx.user_id) + await msg.reply(messages.render_roster(entries)) + + +@_reply_on_error +async def leave(ctx: BotContext, msg: Message) -> None: + """Take the sender off the roster, keeping the direct contact.""" + member = group_sender(msg) + if member is None: + return + contact = await _contact_of(ctx, member) + if contact is None or roster.entry_of(contact) is None: + await msg.reply(messages.NOT_ON_ROSTER) + return + await roster.unmark(ctx.api, contact) + await msg.reply(messages.LEFT) + + +@_reply_on_error +async def help_cmd(ctx: BotContext, msg: Message) -> None: + """Reply with the help text.""" + await msg.reply(messages.HELP) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/health.py b/apps/simplex-support-bot-light/src/support_bot_light/health.py new file mode 100644 index 0000000000..dbef16306e --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/health.py @@ -0,0 +1,166 @@ +"""Optional HTTP endpoint reporting whether the core still answers.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging + +from simplex_chat import ChatError + +from .config import ConfigError, Health +from .context import BotContext + +log = logging.getLogger(__name__) + +PATH = "/health" + +# The probe issues a real command, so it has to give up before the monitor does. +PROBE_TIMEOUT = 5.0 + +# Larger than any request a monitor sends, and the cap on what is read. +MAX_REQUEST_BYTES = 4096 +READ_TIMEOUT = 5.0 + + +def _head(status: str, length: int) -> bytes: + return ( + f"HTTP/1.1 {status}\r\n" + "Content-Type: application/json\r\n" + f"Content-Length: {length}\r\n" + "Connection: close\r\n\r\n" + ).encode() + + +def _response(status: str, payload: str) -> tuple[bytes, bytes]: + """(head, body). HEAD answers with the head alone, as HTTP requires.""" + body = f'{{"status":"{payload}"}}\n'.encode() + return _head(status, len(body)), body + + +OK = _response("200 OK", "ok") +UNAVAILABLE = _response("503 Service Unavailable", "unavailable") +NOT_FOUND = _response("404 Not Found", "not found") +NOT_ALLOWED = _response("405 Method Not Allowed", "method not allowed") +BAD_REQUEST = _response("400 Bad Request", "bad request") + + +class Probe: + """One outstanding query at a time, however often the endpoint is polled. + + `asyncio.wait_for` bounds the wait, not the work: the FFI call it abandons + keeps a worker thread in the loop's default executor until the core answers. + Starting a fresh one per poll would exhaust that executor — as few as six + threads on a small container — and the receive loop reads events through the + same executor, so a stalled core would take the bot's own traffic down with + it. The task is therefore reused rather than replaced, and never cancelled. + """ + + def __init__(self, ctx: BotContext) -> None: + self._ctx = ctx + self._task: asyncio.Task[bool] | None = None + + async def check(self) -> bool: + """Whether the core answered within PROBE_TIMEOUT.""" + task = self._task + if task is None or task.done(): + task = asyncio.create_task(self._query()) + self._task = task + done, _pending = await asyncio.wait({task}, timeout=PROBE_TIMEOUT) + if not done: + log.warning("health probe still waiting after %ss", PROBE_TIMEOUT) + return False + return task.result() + + async def _query(self) -> bool: + """Query the roster group. Never raises, whatever the core does. + + Reaching the process proves only that the event loop runs. This reads + the database, so it also waits on the store lock every other operation + takes — unlike `/u`, which the core answers from memory and which would + report healthy while a transaction was wedged. It stays small: the + roster group holds the people who answer, not customers. + """ + try: + await self._ctx.api.api_list_members(self._ctx.roster_group_id) + except ChatError: + log.warning("health probe failed", exc_info=True) + return False + except Exception: + # A malformed reply or a controller that is gone are exactly what + # this endpoint exists to report, and both arrive as something other + # than a chat error. + log.warning("health probe could not reach the core", exc_info=True) + return False + return True + + +async def _handle( + probe: Probe, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, +) -> None: + try: + try: + line = await asyncio.wait_for(reader.readline(), READ_TIMEOUT) + except ValueError: + # Longer than MAX_REQUEST_BYTES: answered rather than dropped, so a + # monitor sees a reason. + _write(writer, BAD_REQUEST, body=True) + await writer.drain() + return + + request = line.decode("latin-1").split() + method = request[0] if request else "" + if len(request) < 2 or request[1].split("?")[0] != PATH: + _write(writer, NOT_FOUND, body=True) + elif method not in ("GET", "HEAD"): + _write(writer, NOT_ALLOWED, body=True) + else: + _write(writer, OK if await probe.check() else UNAVAILABLE, body=method == "GET") + await writer.drain() + except (TimeoutError, OSError): + # A client that stopped sending, or went away mid-response. + log.debug("health request dropped", exc_info=True) + finally: + writer.close() + with contextlib.suppress(OSError): + await writer.wait_closed() + + +def _write(writer: asyncio.StreamWriter, response: tuple[bytes, bytes], body: bool) -> None: + head, payload = response + writer.write(head + payload if body else head) + + +async def serve(ctx: BotContext, config: Health) -> asyncio.Server | None: + """Start the endpoint, or None when the default port is already taken. + + A port the config names has to work: monitoring that silently failed to + listen reads as health. The default port is different — nothing about it was + asked for, so an unrelated service on it must not keep the bot from running. + """ + probe = Probe(ctx) + + async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + await _handle(probe, reader, writer) + + try: + server = await asyncio.start_server( + handle, config.host, config.port, limit=MAX_REQUEST_BYTES + ) + except OSError as e: + if config.configured: + raise ConfigError( + f"health endpoint cannot listen on {config.host}:{config.port}: {e}" + ) from e + log.warning( + "No health endpoint: the default %s:%s could not be bound (%s). Set " + "health.host or health.port, or health.enabled = false.", + config.host, + config.port, + e, + ) + return None + log.info("Health endpoint: http://%s:%s%s", config.host, config.port, PATH) + return server diff --git a/apps/simplex-support-bot-light/src/support_bot_light/messages.py b/apps/simplex-support-bot-light/src/support_bot_light/messages.py new file mode 100644 index 0000000000..f6e136e7ba --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/messages.py @@ -0,0 +1,113 @@ +"""Every user-visible string, and roster rendering.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from .roster import ACTIVE, RosterEntry + +ADDED = "You are on the roster. You will be added to new chats." +ALREADY_ACTIVE = "You are already on the roster." +INVITATION_SENT = "Contact request sent. Accept it to join the roster." +STILL_PENDING = ( + "Contact request not accepted yet. If you declined it, leave this group, " + "join it again with the link, then run /dm." +) +INVITATION_FAILED = "Contact request could not be sent. Run /dm again." +ACCEPTING = "Accepting the connection you started. You will be on the roster shortly." +CONNECTING = "The connection is still completing. You will be on the roster shortly." +CONNECTION_LOST = ( + "The direct connection is gone, so I cannot add you to chats. Open my " + "profile in this group, connect directly, then run /dm." +) +INVITATION_TEXT = "Accept this contact request to be added to incoming chats. Keep the contact." +NOW_ACTIVE = "Now on the roster: {name}" +REMOVED_FROM_GROUP = "Off the roster: {name} left the roster group." +LEFT = "You are off the roster. Chats you have already joined are unchanged." +NOT_ON_ROSTER = "You are not on the roster." +ROSTER_EMPTY = "The roster is empty." + +# Keeps /list under the core's per-message size limit. +MAX_LISTED = 40 + +# Below the core's maxEncodedMsgLength (Protocol.hs). +MAX_REPLY_BYTES = 12000 +TRUNCATED = "\n… truncated" +EMPTY_ROSTER_LOG = "Connected: {customer} → nobody on the roster to add" +NOBODY_NEW_LOG = "Connected: {customer} → everyone on the roster was already in the chat" +COMMAND_FAILED = "The command failed. Try again." +REVOKE_FAILED = "Could not take {name} off the roster — retrying on the next restart." +BUSINESS_FAILED_LOG = "Connected: {customer} → could not set up the chat, nobody added" + +HELP = ( + "I add roster members to chats started by anyone who connects to my address.\n\n" + "/dm — join the roster. Without a direct contact I send a contact request; " + "you join the roster once you accept it.\n" + "/list — roster members, and contact requests not yet accepted.\n" + "/leave — leave the roster. Chats you have already joined are unchanged." +) + + +def _since(label: str, since: str) -> str: + """` — since 2026-08-13`, or empty when the entry has no timestamp.""" + day = since[:10] + return f" — {label} {day}" if day else "" + + +def _section(title: str, label: str, entries: Sequence[RosterEntry]) -> list[str]: + """A `/list` section, capped so the whole reply stays sendable. + + A long enough roster would push `/list` past the core's wire limit + (maxEncodedMsgLength), so it is capped here and what is omitted is stated + rather than silently dropped. + """ + lines = [f"{title} ({len(entries)}):"] + lines += [f" • {e.name}{_since(label, e.since)}" for e in entries[:MAX_LISTED]] + if len(entries) > MAX_LISTED: + lines.append(f" … and {len(entries) - MAX_LISTED} more") + return lines + + +def render_roster(entries: Sequence[RosterEntry]) -> str: + """Format the roster for `/list`, with a section per state.""" + active = [e for e in entries if e.state == ACTIVE and e.reachable] + unreachable = [e for e in entries if e.state == ACTIVE and not e.reachable] + pending = [e for e in entries if e.state != ACTIVE] + + lines: list[str] = [] + if active: + lines += _section("On the roster", "since", active) + else: + lines.append(ROSTER_EMPTY) + if unreachable: + lines.append("") + lines += _section("Not reachable, not being added", "since", unreachable) + if pending: + lines.append("") + lines += _section("Contact request not accepted", "asked", pending) + + return _bounded("\n".join(lines)) + + +def _bounded(out: str) -> str: + """Keep a message inside what the core will send.""" + encoded = out.encode() + if len(encoded) > MAX_REPLY_BYTES: + # A last resort: names are capped in characters, so a section of CJK + # names can still overrun what the core will send. The suffix is inside + # the budget, so the result never exceeds MAX_REPLY_BYTES. + room = MAX_REPLY_BYTES - len(TRUNCATED.encode()) + return encoded[:room].decode(errors="ignore") + TRUNCATED + return out + + +def invite_log(customer: str, added: Sequence[str], failed: Sequence[str]) -> str: + """One line for the roster group recording who was pulled into a business chat.""" + line = ( + f"Connected: {customer} → added {', '.join(added)}" + if added + else f"Connected: {customer} → nobody added" + ) + if failed: + line += f" (failed: {', '.join(failed)})" + return _bounded(line) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/roster.py b/apps/simplex-support-bot-light/src/support_bot_light/roster.py new file mode 100644 index 0000000000..1fc85369f4 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/roster.py @@ -0,0 +1,170 @@ +"""Roster membership, stored in contact custom data.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Literal + +from simplex_chat import ChatApi, util +from simplex_chat.types import T + +from .text import safe_name + +NAMESPACE = "supportBotLight" +ACTIVE = "active" +PENDING = "pending" + +RosterState = Literal["active", "pending"] + +READY_STATUSES = frozenset({"ready", "sndReady"}) + +# Mirrors isInGroup in apps/simplex-support-bot/src/bot.ts. +TERMINAL_STATUSES = frozenset({"rejected", "removed", "left", "deleted", "unknown"}) + +# The connection is gone; nothing about it is still in progress. +DEAD_STATUSES = frozenset({"deleted", "failed"}) + + +def in_group(member: T.GroupMember) -> bool: + return member["memberStatus"] not in TERMINAL_STATUSES + + +async def contact_ids_in_group(api: ChatApi, group_id: int) -> set[int]: + """Contact ids of everyone currently in a group. + + api_list_members keeps rows for people who left or were removed, so the + status filter is what makes this a membership test rather than a history of + everyone who was ever in the group. + """ + members = await api.api_list_members(group_id) + return {cid for m in members if (cid := m.get("memberContactId")) is not None and in_group(m)} + + +def connecting(contact: T.Contact) -> bool: + """Whether a connection is still on its way up. + + Between accepting and `ready` the core parks a member contact in `accepted` + with `contactGroupMemberId` cleared, which is indistinguishable by shape + from a connection the peer deleted. Only the status separates them. + """ + tag = util.conn_status(contact) + return tag is not None and tag not in DEAD_STATUSES + + +def awaiting_accept(contact: T.Contact) -> bool: + """Whether the peer opened a direct connection we have not accepted. + + A member who taps "connect directly" on the bot's profile in the roster + group produces this: a contact in `prepared` state with no + `contactGroupMemberId`, otherwise indistinguishable from one the peer + deleted. + """ + inv = contact.get("groupDirectInv") + if inv is not None: + # The record survives acceptance; only this flag moves, and the core + # rejects a second accept with "connection already started". + return not inv.get("groupDirectInvStartedConnection", False) + return util.conn_status(contact) == "prepared" + + +def accept_started(contact: T.Contact) -> bool: + """Whether we accepted and the connection is still completing. + + UPSTREAM BUG: `groupDirectInv` outlives the connection it describes. Nothing + clears the record when that connection dies, so the started flag alone + reports progress on a contact the peer deleted long ago. + + Workaround: the connection status decides, and the flag only distinguishes + accepted from not yet accepted. + """ + inv = contact.get("groupDirectInv") + if inv is None or not inv.get("groupDirectInvStartedConnection", False): + return False + return util.conn_status(contact) not in DEAD_STATUSES + + +def contact_usable(contact: T.Contact) -> bool: + """Whether the bot can actually add this contact to a group. + + `api_create_member_contact` sets the member's contact id before the person + has accepted anything, so the contact merely existing proves nothing — only + a connected connection does. + """ + return util.conn_status(contact) in READY_STATUSES + + +@dataclass(frozen=True, slots=True) +class RosterEntry: + """One person on the roster, as recorded in their contact's custom data.""" + + contact_id: int + name: str + state: RosterState + since: str + reachable: bool + + +def utc_now() -> str: + """Current UTC time as an ISO-8601 string, second precision.""" + return datetime.now(UTC).isoformat(timespec="seconds") + + +def contact_name(contact: T.Contact) -> str: + """A contact's display name, sanitised for rendering.""" + return safe_name( + contact.get("localDisplayName") or (contact.get("profile") or {}).get("displayName") or "" + ) + + +def entry_of(contact: T.Contact) -> RosterEntry | None: + """The roster entry for a contact, or None if it carries no roster mark.""" + mark = (contact.get("customData") or {}).get(NAMESPACE) + if not isinstance(mark, dict): + return None + state = mark.get("roster") + if state != ACTIVE and state != PENDING: + return None + return RosterEntry( + contact_id=contact["contactId"], + name=contact_name(contact), + state=state, + since=str(mark.get("since", "")), + reachable=contact_usable(contact), + ) + + +async def mark(api: ChatApi, contact: T.Contact, state: RosterState, since: str) -> None: + """Write the roster mark, preserving any other keys in the blob.""" + await api.api_merge_contact_custom_data(contact, NAMESPACE, {"roster": state, "since": since}) + + +async def unmark(api: ChatApi, contact: T.Contact) -> None: + """Remove the roster mark, leaving any other keys and the contact intact.""" + await api.api_merge_contact_custom_data(contact, NAMESPACE, None) + + +async def load(api: ChatApi, user_id: int) -> list[RosterEntry]: + """Every marked contact, sorted by display name.""" + contacts = await api.api_list_contacts(user_id) + entries = [e for c in contacts if (e := entry_of(c)) is not None] + return sorted(entries, key=lambda e: e.name.lower()) + + +async def active(api: ChatApi, user_id: int) -> list[RosterEntry]: + """Marked active and still reachable — the ones added to business chats. + + A contact marked active can stop being usable later, for instance when the + person deletes the bot. `api_add_member` always fails for such a contact, so + it is excluded here rather than failing once per business chat forever. + `/list` reports the same distinction under "Not reachable". + """ + return [e for e in await load(api, user_id) if e.state == ACTIVE and e.reachable] + + +async def find_contact(api: ChatApi, user_id: int, contact_id: int) -> T.Contact | None: + """The contact with this id, or None if it no longer exists.""" + for c in await api.api_list_contacts(user_id): + if c["contactId"] == contact_id: + return c + return None diff --git a/apps/simplex-support-bot-light/src/support_bot_light/setup.py b/apps/simplex-support-bot-light/src/support_bot_light/setup.py new file mode 100644 index 0000000000..b7be384abd --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/setup.py @@ -0,0 +1,146 @@ +"""Find or create the roster group, and keep its command menu in sync.""" + +from __future__ import annotations + +import asyncio +import logging + +from simplex_chat import ChatApi, ChatError +from simplex_chat.types import T + +from . import commands, roster +from .config import Config + +log = logging.getLogger(__name__) + +GROUP_MARKER = "roster" +JOIN_ROLE: T.GroupMemberRole = "member" + +# api_update_group_profile broadcasts, and the core's view queue is bounded +# (tbqSize in Mobile.hs) with a blocking write. Nothing drains that queue until +# the bot serves, so after enough downtime this call cannot return. The bot must +# start anyway: the write completes once the queue drains, and a stale menu is a +# cosmetic problem next to a process that never gets there. +PROFILE_PUSH_TIMEOUT = 30.0 + + +def is_roster_group(group: T.GroupInfo) -> bool: + """Whether this is a roster group the bot is still in. + + api_list_groups keeps groups the bot has left or been removed from. Without + the membership test the marker on a dead group would be chosen on every + start: no command would ever arrive, nothing could be posted, and the marker + would keep a replacement from being created. + """ + mark = (group.get("customData") or {}).get(roster.NAMESPACE) + if not isinstance(mark, dict) or mark.get("group") != GROUP_MARKER: + return False + return roster.in_group(group["membership"]) + + +def _preferences() -> T.GroupPreferences: + return { + "directMessages": {"enable": "on"}, + "commands": commands.to_wire(commands.COMMANDS), + } + + +async def _get_or_create_group_link(api: ChatApi, group_id: int) -> str | None: + """The group's join link, creating one if it doesn't exist yet. + + A link can be missing if the process died between marking the group and + creating the link on a previous run — that must not leave the group + permanently unjoinable. + + `api_get_group_link_str` also fails for reasons other than "no link + exists" — if that happens while a link is actually present, the fallback + create hits the group's unique link index and raises too. A missing link + must never block startup, so that failure is logged and swallowed rather + than left to propagate out of `ensure_roster_group`. + """ + try: + return await api.api_get_group_link_str(group_id) + except ChatError: + pass + try: + return await api.api_create_group_link(group_id, JOIN_ROLE) + except ChatError: + log.warning( + "Could not get or create a join link for roster group %s", group_id, exc_info=True + ) + return None + + +async def ensure_roster_group(api: ChatApi, user_id: int, config: Config) -> int: + """Return the roster group id, creating the group on first run. + + The group is identified by a marker in its custom data, not by name, so an + operator renaming it in the client doesn't cause a second group to appear. + """ + marked = [g for g in await api.api_list_groups(user_id) if is_roster_group(g)] + if len(marked) > 1: + # Reachable when two instances share a database, or after a database is + # restored. Members of the group not chosen here are talking to a bot + # that ignores them, so say which one won. + log.warning( + "%d groups carry the roster marker (%s); using %s", + len(marked), + ", ".join(str(g["groupId"]) for g in marked), + marked[0]["groupId"], + ) + if marked: + group = marked[0] + try: + await _sync_preferences(api, group) + except ChatError: + # The menu is a convenience; the commands work when typed. The core + # requires owner rights to update the profile, so an operator who + # demotes the bot would otherwise brick every later start. + log.warning("could not update the command menu", exc_info=True) + group_id = group["groupId"] + log.info("Roster group: %s:%s", group_id, group["localDisplayName"]) + else: + profile: T.GroupProfile = { + "displayName": config.group_name, + "fullName": "", + "groupPreferences": _preferences(), + } + group = await api.api_new_group(user_id, profile) + group_id = group["groupId"] + await api.api_set_group_custom_data(group_id, {roster.NAMESPACE: {"group": GROUP_MARKER}}) + log.info("Roster group created: %s", group_id) + + link = await _get_or_create_group_link(api, group_id) + if link is not None: + log.info("Roster group link (share with the people who should answer):\n%s", link) + return group_id + + +async def _sync_preferences(api: ChatApi, group: T.GroupInfo) -> None: + """Restore the preferences the roster group needs, only when they differ. + + Both matter: without `commands` there is no menu, and without + `directMessages` the core refuses to create a member contact, so `/dm` + fails with nothing to explain it. An owner can switch either off in a + client, so neither can be assumed to survive from creation. + + `api_update_group_profile` broadcasts to every member, so a no-op update is + traffic for everyone in the group. + """ + profile = group.get("groupProfile") or {} + prefs = profile.get("groupPreferences") or {} + desired = _preferences() + if all(prefs.get(key) == value for key, value in desired.items()): + return + updated: T.GroupProfile = {**profile, "groupPreferences": {**prefs, **desired}} + try: + await asyncio.wait_for( + api.api_update_group_profile(group["groupId"], updated), PROFILE_PUSH_TIMEOUT + ) + except TimeoutError: + log.warning( + "Roster group preferences are still being written after %ss; continuing", + PROFILE_PUSH_TIMEOUT, + ) + return + log.info("Restored roster group preferences on %s", group["groupId"]) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/text.py b/apps/simplex-support-bot-light/src/support_bot_light/text.py new file mode 100644 index 0000000000..e66bb44c10 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/text.py @@ -0,0 +1,48 @@ +"""Sanitising peer-controlled text before it is rendered.""" + +from __future__ import annotations + +import unicodedata + +# mkValidName in src/Simplex/Chat/Library/Commands.hs caps a locally entered +# name at 50 characters. It is not applied to inbound profiles. +MAX_NAME = 50 + +UNNAMED = "(unnamed)" + +# Characters that render as nothing but are neither whitespace nor a control +# category, so `str.split` and `str.isprintable` both let them through. A stock +# client accepts them in a profile name, which makes "ㅤㅤAlice" a +# working impersonation of "Alice". +# Separators the bot's own messages use. A customer chooses their display name, +# and the roster group is the operator's only record of who was added. +SEPARATORS = frozenset("→") + +INVISIBLE = frozenset( + "ᅟᅠㅤᅠ" # Hangul fillers + "⠀" # Braille pattern blank + "឴឵" # Khmer inherent vowels + "⁠" # word joiner, zero-width no-break space +) + + +def safe_name(name: str) -> str: + """Collapse and truncate a display name for rendering. + + The core does not sanitise inbound profiles: a peer's display name reaches + us verbatim and may contain newlines or run to kilobytes. Rendered as-is it + forges lines in the roster group and in the log, and can push a message past + the size the core will send. + """ + # NFKC folds compatibility forms, so a name cannot hide behind an exotic + # encoding of an ordinary character. + collapsed = " ".join(unicodedata.normalize("NFKC", name).split()) + printable = "".join( + c for c in collapsed if c.isprintable() and c not in INVISIBLE and c not in SEPARATORS + ) + stripped = printable.strip() + if not stripped: + return UNNAMED + if len(stripped) > MAX_NAME: + return stripped[: MAX_NAME - 1] + "…" + return stripped diff --git a/apps/simplex-support-bot-light/state/.gitkeep b/apps/simplex-support-bot-light/state/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/simplex-support-bot-light/tests/__init__.py b/apps/simplex-support-bot-light/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/simplex-support-bot-light/tests/conftest.py b/apps/simplex-support-bot-light/tests/conftest.py new file mode 100644 index 0000000000..d13e012589 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/conftest.py @@ -0,0 +1,275 @@ +"""Fake ChatApi and wire-object factories. No libsimplex, no I/O.""" + +from __future__ import annotations + +import copy +from types import SimpleNamespace +from typing import Any + +import pytest +from simplex_chat import Message, util +from simplex_chat.core import ChatAPIError + +USER_ID = 1 +ROSTER_GROUP_ID = 10 + + +class FakeChatApi: + """Records calls; returns canned wire dicts. + + `fail_on` is a set of method names that raise `ChatAPIError` when called, + used to drive the partial-failure paths. + """ + + def __init__(self, contacts: list[dict] | None = None, fail_on: set[str] | None = None): + self.contacts = contacts or [] + self.groups: list[dict] = [] + self.members: dict[int, list[dict]] = {} + self.fail_on = fail_on or set() + self.custom_data: list[tuple[int, dict | None]] = [] + self.group_custom_data: list[tuple[int, dict | None]] = [] + self.replies: list[str] = [] + self.sent: list[tuple[Any, str]] = [] + self.added: list[tuple[int, int, str]] = [] + self.created_member_contacts: list[tuple[int, int]] = [] + self.invitations: list[tuple[int, str]] = [] + self.new_groups: list[dict] = [] + self.profile_updates: list[tuple[int, dict]] = [] + self.links: list[int] = [] + self.group_links: dict[int, str] = {} + self._next_contact_id = 100 + self._next_item_id = 1000 + self._member_contacts_created: set[tuple[int, int]] = set() + self.accepted_member_contacts: list[int] = [] + + def _check(self, name: str) -> None: + if name in self.fail_on: + raise ChatAPIError(f"fake failure in {name}", {"type": "chatCmdError"}) + + async def api_list_contacts(self, user_id: int) -> list[dict]: + self._check("api_list_contacts") + # Copies, like the core: a caller holding a contact does not see a later + # write to it, so stale reads show up in tests instead of in production. + return copy.deepcopy(self.contacts) + + async def api_set_contact_custom_data(self, contact_id: int, custom_data=None) -> None: + self._check("api_set_contact_custom_data") + self.custom_data.append((contact_id, custom_data)) + for c in self.contacts: + if c["contactId"] == contact_id: + if custom_data is None: + c.pop("customData", None) + else: + c["customData"] = custom_data + + async def api_merge_contact_custom_data(self, contact: dict, key: str, value) -> None: + # Mirrors ChatApi: the column is replaced wholesale, so a merge is a + # read-modify-write through the same set command. + await self.api_set_contact_custom_data( + contact["contactId"], util.merged_custom_data(contact.get("customData"), key, value) + ) + + async def api_merge_group_custom_data(self, group: dict, key: str, value) -> None: + await self.api_set_group_custom_data( + group["groupId"], util.merged_custom_data(group.get("customData"), key, value) + ) + + async def api_create_member_contact(self, group_id: int, group_member_id: int) -> dict: + self._check("api_create_member_contact") + key = (group_id, group_member_id) + if key in self._member_contacts_created: + raise ChatAPIError("member contact already exists", {"type": "chatCmdError"}) + self._member_contacts_created.add(key) + self.created_member_contacts.append((group_id, group_member_id)) + contact = make_contact(self._next_contact_id, f"member{group_member_id}") + self._next_contact_id += 1 + self.contacts.append(contact) + return contact + + async def api_send_member_contact_invitation(self, contact_id: int, message=None) -> dict: + self._check("api_send_member_contact_invitation") + for c in self.contacts: + if c["contactId"] == contact_id and c.get("contactGrpInvSent"): + raise ChatAPIError("x.grp.direct.inv already sent", {"type": "chatCmdError"}) + self.invitations.append((contact_id, message)) + for c in self.contacts: + if c["contactId"] == contact_id: + c["contactGrpInvSent"] = True + return make_contact(contact_id, "invited", grp_inv_sent=True) + + async def api_accept_member_contact(self, contact_id: int) -> dict: + self._check("api_accept_member_contact") + self.accepted_member_contacts.append(contact_id) + for c in self.contacts: + if c["contactId"] == contact_id: + c.setdefault("groupDirectInv", {})["groupDirectInvStartedConnection"] = True + return c + return make_contact(contact_id, "accepted") + + async def api_list_members(self, group_id: int) -> list[dict]: + self._check("api_list_members") + return list(self.members.get(group_id, [])) + + async def api_add_member(self, group_id: int, contact_id: int, member_role: str) -> dict: + self._check("api_add_member") + self.added.append((group_id, contact_id, member_role)) + # Distinct id spaces; keep them apart so a mix-up shows up. + return make_member(group_member_id=contact_id + 1000, contact_id=contact_id) + + async def api_send_text_message(self, chat, text: str, in_reply_to=None) -> list: + self._check("api_send_text_message") + self.sent.append((chat, text)) + return [] + + async def api_send_text_reply(self, chat_item, text: str) -> list: + self._check("api_send_text_reply") + self.replies.append(text) + # Message.reply indexes items[0], so this cannot return []. + self._next_item_id += 1 + sent_item = { + "chatInfo": chat_item["chatInfo"], + "chatItem": { + "chatDir": {"type": "direct"}, + "meta": {"itemId": self._next_item_id}, + "content": {"type": "sndMsgContent", "msgContent": {"type": "text", "text": text}}, + }, + } + return [sent_item] + + async def api_list_groups(self, user_id: int, contact_id=None, search=None) -> list[dict]: + self._check("api_list_groups") + return list(self.groups) + + async def api_new_group(self, user_id: int, group_profile: dict) -> dict: + self._check("api_new_group") + self.new_groups.append(group_profile) + group = make_group(ROSTER_GROUP_ID, group_profile) + self.groups.append(group) + return group + + async def api_set_group_custom_data(self, group_id: int, custom_data=None) -> None: + self._check("api_set_group_custom_data") + self.group_custom_data.append((group_id, custom_data)) + for g in self.groups: + if g["groupId"] == group_id: + g["customData"] = custom_data + + async def api_update_group_profile(self, group_id: int, group_profile: dict) -> dict: + self._check("api_update_group_profile") + self.profile_updates.append((group_id, group_profile)) + return make_group(group_id, group_profile) + + async def api_create_group_link(self, group_id: int, member_role: str) -> str: + self._check("api_create_group_link") + self.links.append(group_id) + link = f"https://simplex.chat/contact#/?v=2&group={group_id}" + self.group_links[group_id] = link + return link + + async def api_get_group_link_str(self, group_id: int) -> str: + self._check("api_get_group_link_str") + try: + return self.group_links[group_id] + except KeyError: + raise ChatAPIError("no group link", {"type": "chatCmdError"}) from None + + +def make_contact( + contact_id: int, + name: str, + custom_data: dict | None = None, + connected: bool = False, + grp_inv_sent: bool = False, + grp_member_id: int | None = -1, + conn_status: str | None = None, +) -> dict: + contact: dict = { + "contactId": contact_id, + "localDisplayName": name, + "profile": {"profileId": contact_id, "displayName": name, "fullName": ""}, + "contactGrpInvSent": grp_inv_sent, + } + if custom_data is not None: + contact["customData"] = custom_data + if conn_status is not None: + contact["activeConn"] = {"connStatus": {"type": conn_status}} + elif connected: + contact["activeConn"] = {"connStatus": {"type": "ready"}} + # The core sets contactGroupMemberId when a member contact is created and + # clears it once that contact connects (resetMemberContactFields). -1 means + # "use whichever of those matches `connected`". + if grp_member_id == -1: + grp_member_id = None if connected else contact_id + if grp_member_id is not None: + contact["contactGroupMemberId"] = grp_member_id + return contact + + +def make_member( + group_member_id: int = 1, + contact_id: int | None = None, + name: str = "someone", + status: str = "complete", +) -> dict: + member: dict = { + "groupMemberId": group_member_id, + "localDisplayName": name, + "memberProfile": {"displayName": name, "fullName": ""}, + "memberStatus": status, + } + if contact_id is not None: + member["memberContactId"] = contact_id + return member + + +def make_group( + group_id: int, + profile: dict, + custom_data: dict | None = None, + membership_status: str = "creator", +) -> dict: + # The core always sends membership; discovery reads it to skip groups the + # bot has left. + group: dict = { + "groupId": group_id, + "groupProfile": profile, + "localDisplayName": "g", + "membership": make_member(1, name="bot", status=membership_status), + } + if custom_data is not None: + group["customData"] = custom_data + return group + + +def join_roster_group(api: FakeChatApi) -> None: + """Put every contact in the roster group. + + Being on the roster means being in that group; the bot re-checks it before + adding anyone to a customer's chat, so tests have to model it. + """ + api.members[ROSTER_GROUP_ID] = [ + make_member(1000 + c["contactId"], contact_id=c["contactId"], name=c["localDisplayName"]) + for c in api.contacts + ] + + +def make_group_message(api: FakeChatApi, member: dict, text: str, group_id: int = ROSTER_GROUP_ID): + """A `Message` as delivered from a group, wired to the fake api.""" + chat_item = { + "chatInfo": {"type": "group", "groupInfo": make_group(group_id, {"displayName": "r"})}, + "chatItem": { + "chatDir": {"type": "groupRcv", "groupMember": member}, + "meta": {"itemId": 1}, + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": text}}, + }, + } + return Message( + chat_item=chat_item, + content={"type": "text", "text": text}, + client=SimpleNamespace(api=api), + ) + + +@pytest.fixture +def api() -> FakeChatApi: + return FakeChatApi() diff --git a/apps/simplex-support-bot-light/tests/test_boundaries.py b/apps/simplex-support-bot-light/tests/test_boundaries.py new file mode 100644 index 0000000000..b34158c336 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_boundaries.py @@ -0,0 +1,124 @@ +"""Constants and boundaries pinned at their exact edge. + +Each of these was a surviving mutant: the value could be moved by one, or a +member of a set removed, with the whole suite still green. +""" + +import pytest + +from support_bot_light import commands, config, messages, roster, setup, text +from support_bot_light.config import ConfigError, load_config +from tests.conftest import make_contact, make_member +from tests.test_config import VALID, write + + +def entry(name: str, state: str = "active", reachable: bool = True) -> roster.RosterEntry: + return roster.RosterEntry( + contact_id=1, name=name, state=state, since="2026-08-13", reachable=reachable + ) + + +def test_the_roster_group_link_hands_out_the_member_role(): + # An owner could remove the bot from its own roster group. + assert setup.JOIN_ROLE == "member" + + +def test_both_ready_statuses_make_a_contact_usable(): + # contactSndReady is a distinct event from contactConnected, and a member + # promoted by one must not be treated as unreachable by the other. + for status in ("ready", "sndReady"): + assert roster.contact_usable(make_contact(1, "sh", conn_status=status)) is True + assert roster.contact_usable(make_contact(1, "sh", conn_status="accepted")) is False + + +@pytest.mark.parametrize("status", ["deleted", "failed"]) +def test_a_dead_connection_is_not_accepted_or_connecting(status): + contact = make_contact(1, "sh", conn_status=status) + contact["groupDirectInv"] = {"groupDirectInvLink": "x", "groupDirectInvStartedConnection": True} + assert roster.accept_started(contact) is False + assert roster.connecting(contact) is False + + +def test_a_member_of_the_roster_group_is_in_it_until_a_terminal_status(): + assert roster.in_group(make_member(1, status="pending_approval")) is True + assert roster.in_group(make_member(1, status="invited")) is True + assert roster.in_group(make_member(1, status="left")) is False + + +def test_list_shows_forty_before_it_summarises(): + # 40 keeps the reply inside the core's wire limit with room for two more + # sections; the literal is the point, so a change has to be deliberate. + assert messages.MAX_LISTED == 40 + at_cap = messages.render_roster([entry(f"n{i}") for i in range(40)]) + assert at_cap.count("•") == 40 + assert "more" not in at_cap + + over_cap = messages.render_roster([entry(f"n{i}") for i in range(41)]) + assert over_cap.count("•") == 40 + assert "… and 1 more" in over_cap + + +def test_a_reply_at_the_byte_cap_is_not_truncated(): + room = messages.MAX_REPLY_BYTES - len("On the roster (1):\n • ") - len(" — since 2026-08-13") + assert messages.render_roster([entry("a" * min(room, text.MAX_NAME))]).endswith("2026-08-13") + + over = [entry("漢" * text.MAX_NAME) for _ in range(messages.MAX_LISTED)] + over += [entry("漢" * text.MAX_NAME, state="pending") for _ in range(messages.MAX_LISTED)] + rendered = messages.render_roster(over) + assert len(rendered.encode()) <= messages.MAX_REPLY_BYTES + assert rendered.endswith(messages.TRUNCATED) + + +def test_a_name_of_fifty_is_kept_whole(): + # mkValidName caps a locally entered name at 50; inbound profiles are not + # capped at all, which is why this exists. + assert text.MAX_NAME == 50 + assert text.safe_name("a" * 50) == "a" * 50 + over = text.safe_name("a" * 51) + assert len(over) == 50 and over.endswith("…") + + +def test_a_welcome_of_twelve_thousand_bytes_is_accepted(tmp_path): + assert config.MAX_WELCOME_BYTES == 12000 + at_cap = "w" * 12000 + text_at = VALID.replace('welcome = "Hi! Someone will join shortly."', f'welcome = "{at_cap}"') + assert load_config(write(tmp_path, text_at)).welcome == at_cap + + over = "w" * 12001 + text_over = VALID.replace('welcome = "Hi! Someone will join shortly."', f'welcome = "{over}"') + with pytest.raises(ConfigError, match="too long"): + load_config(write(tmp_path, text_over)) + + +def test_an_image_of_9357_bytes_is_accepted(tmp_path): + # 9357 raw bytes is what a 12500-character data URI holds once base64 and + # the "data:image/png;base64," prefix are added. The pre-read check must + # admit everything the encoded cap can hold, and no more. + assert config.MAX_IMAGE_BYTES == 9357 + at_cap = tmp_path / "a.png" + at_cap.write_bytes(b"\x89PNG" + b"x" * (9357 - 4)) + conf = VALID.replace("[roster]", f'image = "{at_cap}"\n\n[roster]') + assert load_config(write(tmp_path, conf)).image is not None + + over = tmp_path / "b.png" + over.write_bytes(b"\x89PNG" + b"x" * (9358 - 4)) + conf_over = VALID.replace("[roster]", f'image = "{over}"\n\n[roster]') + with pytest.raises(ConfigError, match="too large"): + load_config(write(tmp_path, conf_over)) + + +@pytest.mark.parametrize("port", [1, 65535]) +def test_the_port_range_ends_are_accepted(tmp_path, port): + assert config.MAX_PORT == 65535 + conf = load_config(write(tmp_path, VALID + f"\n[health]\nport = {port}\n")) + assert conf.health is not None and conf.health.port == port + + +def test_the_command_menu_carries_every_command(): + wire = commands.to_wire(commands.COMMANDS) + assert [c["keyword"] for c in wire] == [ + commands.DM, + commands.LIST, + commands.LEAVE, + commands.HELP, + ] diff --git a/apps/simplex-support-bot-light/tests/test_business.py b/apps/simplex-support-bot-light/tests/test_business.py new file mode 100644 index 0000000000..c555cfbf3f --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_business.py @@ -0,0 +1,534 @@ +import pytest +from simplex_chat import ChatCommandError + +from support_bot_light import business, messages +from support_bot_light.config import Config +from support_bot_light.context import BotContext +from tests.conftest import ( + ROSTER_GROUP_ID, + USER_ID, + join_roster_group, + make_contact, + make_group, + make_member, +) + +BUSINESS_GROUP_ID = 42 +CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner") + + +@pytest.fixture +def ctx(api): + return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG) + + +def event(name="Alex"): + return { + "type": "acceptingBusinessRequest", + "groupInfo": make_group(BUSINESS_GROUP_ID, {"displayName": name, "fullName": ""}), + } + + +async def test_adds_active_roster_members(ctx, api): + api.contacts += [ + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact( + 2, "Narasimha", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact(3, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}), + ] + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.added == [ + (BUSINESS_GROUP_ID, 2, "owner"), + (BUSINESS_GROUP_ID, 1, "owner"), + ] + assert api.sent == [(["group", ROSTER_GROUP_ID], "Connected: Alex → added Narasimha, sh")] + + +@pytest.mark.parametrize("status", ["rejected", "removed", "left", "deleted", "unknown"]) +async def test_does_not_add_someone_who_has_left_the_roster_group(ctx, api, status): + # The departure event and a queued business request arrive in whatever order + # the core dispatches them, so an active mark is not authority on its own: + # this is what stops a departed member reading a conversation started after + # they went. + api.contacts += [ + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact( + 2, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + ] + join_roster_group(api) + api.members[ROSTER_GROUP_ID][1]["memberStatus"] = status + await business.on_business_request(ctx, event()) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + assert api.sent == [(["group", ROSTER_GROUP_ID], "Connected: Alex → added sh")] + + +async def test_reconcile_does_not_add_someone_who_has_left_the_roster_group(ctx, api): + api.contacts.append( + make_contact( + 1, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + join_roster_group(api) + api.members[ROSTER_GROUP_ID][0]["memberStatus"] = "removed" + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + api.members[BUSINESS_GROUP_ID] = [] + await business.reconcile_chats(ctx) + assert api.added == [] + + +async def test_skips_members_already_in_the_group(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="invited")] + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.added == [] + assert api.sent[-1][1] == messages.NOBODY_NEW_LOG.format(customer="Alex") + + +async def test_does_not_skip_members_who_left(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="left")] + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + + +async def test_empty_roster_logs_and_adds_nobody(ctx, api): + await business.on_business_request(ctx, event()) + assert api.added == [] + assert api.sent[-1][1] == messages.EMPTY_ROSTER_LOG.format(customer="Alex") + + +async def test_pending_only_roster_counts_as_empty(ctx, api): + api.contacts.append( + make_contact(1, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}) + ) + await business.on_business_request(ctx, event()) + assert api.added == [] + assert api.sent[-1][1] == messages.EMPTY_ROSTER_LOG.format(customer="Alex") + + +async def test_one_failure_does_not_block_the_rest(ctx, api): + api.contacts += [ + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact( + 2, "Narasimha", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + ] + calls: list[int] = [] + original = api.api_add_member + + async def flaky(group_id, contact_id, member_role): + calls.append(contact_id) + if contact_id == 2: + raise ChatCommandError("nope", {"type": "chatCmdError"}) + return await original(group_id, contact_id, member_role) + + api.api_add_member = flaky + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert sorted(calls) == [1, 2] # both attempted + assert api.sent[-1][1] == "Connected: Alex → added sh (failed: Narasimha)" + + +async def test_roster_read_failure_logs_and_adds_nobody(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.fail_on.add("api_list_contacts") + await business.on_business_request(ctx, event()) + assert api.added == [] + assert api.sent[-1][1] == messages.BUSINESS_FAILED_LOG.format(customer="Alex") + + +async def test_member_list_failure_logs_and_adds_nobody(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.fail_on.add("api_list_members") + await business.on_business_request(ctx, event()) + assert api.added == [] + assert api.sent[-1][1] == messages.BUSINESS_FAILED_LOG.format(customer="Alex") + + +async def test_uses_configured_member_role(api): + ctx = BotContext( + api=api, + user_id=USER_ID, + roster_group_id=ROSTER_GROUP_ID, + config=Config("S", "./x", "hi", "R", "admin"), + ) + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.added == [(BUSINESS_GROUP_ID, 1, "admin")] + + +async def test_reconcile_repairs_a_chat_left_half_added_by_a_crash(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + api.members[BUSINESS_GROUP_ID] = [] + join_roster_group(api) + await business.reconcile_chats(ctx) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + assert api.sent[-1][1] == "Connected: Alex → added sh" + + +async def test_reconcile_is_idempotent_when_everyone_is_present(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="complete")] + join_roster_group(api) + await business.reconcile_chats(ctx) + assert api.added == [] + # The chat was left unmarked, so a crash took the roster group's record of + # this customer with it; the repair puts it back even with nothing to add. + assert api.sent[-1][1] == messages.NOBODY_NEW_LOG.format(customer="Alex") + + +async def test_reconcile_skips_non_business_groups(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": ROSTER_GROUP_ID, + "groupProfile": {"displayName": "roster", "fullName": ""}, + "localDisplayName": "roster", + "membership": make_member(99, name="bot", status="complete"), + } + ) + join_roster_group(api) + await business.reconcile_chats(ctx) + assert api.added == [] + + +async def test_reconcile_skips_a_chat_the_bot_has_left(ctx, api): + # The core keeps the group row after removal; adding into it would fail on + # every start, and the customer is no longer the bot's to serve. + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="removed"), + } + ) + api.members[BUSINESS_GROUP_ID] = [] + join_roster_group(api) + await business.reconcile_chats(ctx) + assert api.added == [] + assert api.group_custom_data == [] + + +async def test_reconcile_failure_does_not_stop_startup(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.fail_on.add("api_list_groups") + join_roster_group(api) + await business.reconcile_chats(ctx) # must not raise + + +async def test_reconcile_skips_a_chat_whose_roster_pass_already_ran(ctx, api): + # Someone who joins the roster later must not be back-filled into every + # conversation the bot has ever handled. + api.contacts.append( + make_contact( + 1, "newbie", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + "customData": {"supportBotLight": {"rostered": True}}, + } + ) + api.members[BUSINESS_GROUP_ID] = [] + join_roster_group(api) + await business.reconcile_chats(ctx) + assert api.added == [] + assert api.sent == [] + + +async def test_reconcile_does_not_re_invite_someone_who_left_a_chat(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + "customData": {"supportBotLight": {"rostered": True}}, + } + ) + api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="left")] + join_roster_group(api) + await business.reconcile_chats(ctx) + assert api.added == [] + + +async def test_on_business_request_marks_the_chat_as_rostered(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.group_custom_data[-1] == ( + BUSINESS_GROUP_ID, + {"supportBotLight": {"rostered": True}}, + ) + + +async def test_reconcile_marks_chats_even_with_an_empty_roster(ctx, api): + # Otherwise the chat stays unmarked and a later restart back-fills whoever + # joined the roster in the meantime. + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + await business.reconcile_chats(ctx) + assert api.group_custom_data[-1] == ( + BUSINESS_GROUP_ID, + {"supportBotLight": {"rostered": True}}, + ) + assert api.added == [] + + +async def test_a_failed_mark_does_not_report_nobody_added(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.fail_on.add("api_set_group_custom_data") + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + assert api.sent[-1][1] == "Connected: Alex → added sh" + + +async def test_a_chat_where_every_add_failed_is_retried_next_start(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.fail_on.add("api_add_member") + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.group_custom_data == [] # not marked, so repair will revisit it + + +async def test_a_chat_left_unmarked_is_not_back_filled_with_a_later_roster(ctx, api): + # The one bit that keeps a new roster member out of old conversations. + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + join_roster_group(api) + api.fail_on.add("api_send_text_message") + await business.on_business_request(ctx, event()) + api.fail_on.clear() + + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + "customData": api.group_custom_data[-1][1], + } + ) + api.contacts.append( + make_contact( + 2, "newbie", {"supportBotLight": {"roster": "active", "since": "y"}}, connected=True + ) + ) + join_roster_group(api) + api.added.clear() + await business.reconcile_chats(ctx) + assert api.added == [] + + +async def test_a_chat_is_marked_even_when_its_line_never_went_out(ctx, api): + # An unmarked chat is repaired by every later start with the roster of the + # day, so withholding the marker to preserve a log line would hand this + # customer's conversation to whoever joins the roster next. + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + join_roster_group(api) + api.fail_on.add("api_send_text_message") + await business.on_business_request(ctx, event()) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + assert api.group_custom_data[-1][1] == {"supportBotLight": {"rostered": True}} + + +async def test_a_failed_mark_still_reports_the_repaired_chat(ctx, api): + # The marker is re-derived on the next start; the report is not, because + # the event that would have produced it was consumed before the crash. + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + api.members[BUSINESS_GROUP_ID] = [] + join_roster_group(api) + api.fail_on.add("api_set_group_custom_data") + await business.reconcile_chats(ctx) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + assert api.sent[-1][1] == "Connected: Alex → added sh" + + +async def test_an_unfinished_repair_is_retried_by_the_queued_event(ctx, api): + # Nothing was added and the chat was left unmarked, so the event that the + # startup pass raced is the only remaining chance to finish it. + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + api.members[BUSINESS_GROUP_ID] = [] + join_roster_group(api) + api.fail_on.add("api_add_member") + await business.reconcile_chats(ctx) + assert api.group_custom_data == [] # not marked: the repair failed + + api.fail_on.clear() + await business.on_business_request(ctx, event()) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + + +async def test_repair_does_not_re_report_a_chat_to_the_event_handler(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + api.members[BUSINESS_GROUP_ID] = [] + join_roster_group(api) + await business.reconcile_chats(ctx) + posts_after_repair = len(api.sent) + await business.on_business_request(ctx, event()) + assert len(api.sent) == posts_after_repair # the queued event adds no line + + # Only that one event is swallowed: the same customer coming back later + # must be handled like anyone else. + api.members[BUSINESS_GROUP_ID] = [] + await business.on_business_request(ctx, event()) + assert len(api.sent) == posts_after_repair + 1 diff --git a/apps/simplex-support-bot-light/tests/test_commands.py b/apps/simplex-support-bot-light/tests/test_commands.py new file mode 100644 index 0000000000..83c435aacb --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_commands.py @@ -0,0 +1,32 @@ +from simplex_chat import BotCommand + +from support_bot_light.commands import COMMANDS, to_wire + + +def test_declares_four_commands(): + assert tuple(c.keyword for c in COMMANDS) == ("dm", "list", "leave", "help") + + +def test_no_command_takes_params(): + # Zero-argument commands send on tap instead of pasting a placeholder. + assert all(c.params is None for c in COMMANDS) + + +def test_to_wire_omits_params_when_none(): + wire = to_wire([BotCommand(keyword="list", label="Who gets invited")]) + assert wire == [{"type": "command", "keyword": "list", "label": "Who gets invited"}] + assert "params" not in wire[0] + + +def test_to_wire_includes_params_when_set(): + wire = to_wire([BotCommand(keyword="x", label="X", params="")]) + assert wire == [{"type": "command", "keyword": "x", "label": "X", "params": ""}] + + +def test_to_wire_distinguishes_none_from_empty_string(): + assert "params" not in to_wire([BotCommand("a", "A")])[0] + assert to_wire([BotCommand("b", "B", params="")])[0]["params"] == "" + + +def test_to_wire_preserves_declaration_order(): + assert [c["keyword"] for c in to_wire(COMMANDS)] == [c.keyword for c in COMMANDS] diff --git a/apps/simplex-support-bot-light/tests/test_config.py b/apps/simplex-support-bot-light/tests/test_config.py new file mode 100644 index 0000000000..e3c3a7fed5 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_config.py @@ -0,0 +1,286 @@ +import base64 + +import pytest + +from support_bot_light.config import Config, ConfigError, Health, load_config + +VALID = """ +[bot] +display_name = "Support" +db_prefix = "./support_bot_light" +welcome = "Hi! Someone will join shortly." + +[roster] +group_name = "Invite roster" +member_role = "admin" +""" + +# Minimal 1x1 PNG. The loader never parses it, only encodes the bytes. +PNG_BYTES = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde" + b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00\xc9\xfe\x92\xef" + b"\x00\x00\x00\x00IEND\xaeB`\x82" +) + + +def write(tmp_path, text): + p = tmp_path / "config.toml" + p.write_text(text, encoding="utf-8") + return p + + +def test_loads_all_fields(tmp_path): + cfg = load_config(write(tmp_path, VALID)) + assert cfg == Config( + display_name="Support", + db_prefix="./support_bot_light", + welcome="Hi! Someone will join shortly.", + group_name="Invite roster", + member_role="admin", + health=Health(host="127.0.0.1", port=8080), + ) + + +def test_member_role_defaults_to_owner(tmp_path): + text = VALID.replace('member_role = "admin"\n', "") + assert load_config(write(tmp_path, text)).member_role == "owner" + + +def test_rejects_unknown_member_role(tmp_path): + text = VALID.replace('"admin"', '"chief"') + with pytest.raises(ConfigError, match="member_role"): + load_config(write(tmp_path, text)) + + +def test_rejects_missing_key(tmp_path): + text = VALID.replace('welcome = "Hi! Someone will join shortly."\n', "") + with pytest.raises(ConfigError, match="bot.welcome"): + load_config(write(tmp_path, text)) + + +def test_rejects_missing_bot_section(tmp_path): + with pytest.raises(ConfigError, match=r"missing \[bot\] section"): + load_config(write(tmp_path, '[roster]\ngroup_name = "R"\n')) + + +def test_rejects_missing_roster_section(tmp_path): + with pytest.raises(ConfigError, match=r"missing \[roster\] section"): + load_config(write(tmp_path, "[bot]\n")) + + +def test_rejects_empty_string(tmp_path): + text = VALID.replace('"Invite roster"', '" "') + with pytest.raises(ConfigError, match="roster.group_name"): + load_config(write(tmp_path, text)) + + +def test_missing_file(tmp_path): + with pytest.raises(ConfigError, match="not found"): + load_config(tmp_path / "nope.toml") + + +def test_invalid_toml(tmp_path): + with pytest.raises(ConfigError, match="invalid TOML"): + load_config(write(tmp_path, "[bot")) + + +def test_image_defaults_to_none(tmp_path): + assert load_config(write(tmp_path, VALID)).image is None + + +def test_png_image_encodes_with_prefix_and_roundtrips(tmp_path): + (tmp_path / "avatar.png").write_bytes(PNG_BYTES) + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.png"', + ) + image = load_config(write(tmp_path, text)).image + assert image is not None + prefix = "data:image/png;base64," + assert image.startswith(prefix) + assert base64.b64decode(image[len(prefix) :]) == PNG_BYTES + + +@pytest.mark.parametrize("ext", ["jpg", "jpeg"]) +def test_jpg_and_jpeg_extensions_encode_as_jpg(tmp_path, ext): + (tmp_path / f"avatar.{ext}").write_bytes(b"not really a jpeg, just bytes") + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + f'db_prefix = "./support_bot_light"\nimage = "avatar.{ext}"', + ) + image = load_config(write(tmp_path, text)).image + assert image is not None + assert image.startswith("data:image/jpg;base64,") + + +def test_uppercase_extension_accepted(tmp_path): + (tmp_path / "avatar.PNG").write_bytes(PNG_BYTES) + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.PNG"', + ) + image = load_config(write(tmp_path, text)).image + assert image is not None + assert image.startswith("data:image/png;base64,") + + +def test_rejects_unsupported_extension(tmp_path): + (tmp_path / "avatar.gif").write_bytes(b"gif bytes") + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.gif"', + ) + with pytest.raises(ConfigError, match=r"\.gif"): + load_config(write(tmp_path, text)) + + +def test_rejects_missing_image_file(tmp_path): + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "missing.png"', + ) + resolved = tmp_path / "missing.png" + with pytest.raises(ConfigError, match=r"not found.*missing\.png|missing\.png.*not found"): + load_config(write(tmp_path, text)) + assert not resolved.exists() + + +def test_rejects_oversized_image(tmp_path): + # 12500 caps the whole data URI, prefix included. + (tmp_path / "avatar.png").write_bytes(b"\x00" * 20000) + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.png"', + ) + with pytest.raises(ConfigError, match="12500"): + load_config(write(tmp_path, text)) + + +def test_rejects_empty_image_string(tmp_path): + text = VALID.replace( + 'db_prefix = "./support_bot_light"', 'db_prefix = "./support_bot_light"\nimage = " "' + ) + with pytest.raises(ConfigError, match="bot.image"): + load_config(write(tmp_path, text)) + + +def test_relative_image_path_resolves_against_config_dir(tmp_path, monkeypatch): + other_dir = tmp_path / "elsewhere" + other_dir.mkdir() + monkeypatch.chdir(other_dir) + + (tmp_path / "avatar.png").write_bytes(PNG_BYTES) + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.png"', + ) + image = load_config(write(tmp_path, text)).image + assert image is not None + assert base64.b64decode(image[len("data:image/png;base64,") :]) == PNG_BYTES + + +def test_absolute_image_path_works(tmp_path): + image_path = tmp_path / "avatar.png" + image_path.write_bytes(PNG_BYTES) + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + f'db_prefix = "./support_bot_light"\nimage = "{image_path}"', + ) + image = load_config(write(tmp_path, text)).image + assert image is not None + assert base64.b64decode(image[len("data:image/png;base64,") :]) == PNG_BYTES + + +def test_rejects_empty_image_file(tmp_path): + (tmp_path / "avatar.png").write_bytes(b"") + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.png"', + ) + with pytest.raises(ConfigError, match="empty"): + load_config(write(tmp_path, text)) + + +def test_rejects_a_non_regular_image_file(tmp_path): + import os + + os.mkfifo(tmp_path / "avatar.png") + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.png"', + ) + with pytest.raises(ConfigError, match="not a regular file"): + load_config(write(tmp_path, text)) + + +def test_rejects_an_oversized_image_before_reading_it(tmp_path): + (tmp_path / "avatar.png").write_bytes(b"A" * 20000) + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.png"', + ) + with pytest.raises(ConfigError, match="bytes exceeds"): + load_config(write(tmp_path, text)) + + +def test_rejects_an_over_long_welcome(tmp_path): + text = VALID.replace( + 'welcome = "Hi! Someone will join shortly."', 'welcome = "' + "x" * 20000 + '"' + ) + with pytest.raises(ConfigError, match="too long"): + load_config(write(tmp_path, text)) + + +def test_health_is_on_without_configuration(tmp_path): + assert load_config(write(tmp_path, VALID)).health == Health(host="127.0.0.1", port=8080) + + +def test_health_can_be_switched_off(tmp_path): + assert load_config(write(tmp_path, VALID + "\n[health]\nenabled = false\n")).health is None + + +def test_health_port_can_be_set(tmp_path): + config = load_config(write(tmp_path, VALID + "\n[health]\nport = 9999\n")) + assert config.health == Health(host="127.0.0.1", port=9999, configured=True) + + +def test_the_default_port_is_not_treated_as_chosen(tmp_path): + # A port nobody asked for must not be able to stop the bot from starting. + assert load_config(write(tmp_path, VALID)).health == Health("127.0.0.1", 8080) + assert load_config(write(tmp_path, VALID)).health.configured is False + + +def test_health_host_can_be_set(tmp_path): + config = load_config(write(tmp_path, VALID + '\n[health]\nhost = "0.0.0.0"\nport = 9000\n')) + assert config.health == Health(host="0.0.0.0", port=9000, configured=True) + + +@pytest.mark.parametrize( + "section", + [ + '[health]\nenabled = "yes"\n', + "[health]\nport = 0\n", + "[health]\nport = 65536\n", + "[health]\nport = true\n", # TOML booleans are ints in Python + '[health]\nport = "8080"\n', + '[health]\nport = 8080\nhost = " "\n', + ], +) +def test_invalid_health_settings_are_rejected(tmp_path, section): + with pytest.raises(ConfigError): + load_config(write(tmp_path, VALID + "\n" + section)) + + +def test_a_missing_config_points_at_the_template(tmp_path): + # Under Docker this is a restart loop until the operator acts, so the error + # has to say what the action is. + (tmp_path / "config.toml.example").write_text(VALID, encoding="utf-8") + with pytest.raises(ConfigError, match="copy config.toml.example to config.toml"): + load_config(tmp_path / "config.toml") + + +def test_a_missing_config_without_a_template_says_only_that(tmp_path): + with pytest.raises(ConfigError, match="not found") as raised: + load_config(tmp_path / "config.toml") + assert "copy" not in str(raised.value) diff --git a/apps/simplex-support-bot-light/tests/test_handlers.py b/apps/simplex-support-bot-light/tests/test_handlers.py new file mode 100644 index 0000000000..dca98dc2d7 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_handlers.py @@ -0,0 +1,720 @@ +import pytest +from simplex_chat import ChatCommandError + +from support_bot_light import handlers, messages, roster +from support_bot_light.config import Config +from support_bot_light.context import BotContext +from tests.conftest import ( + ROSTER_GROUP_ID, + USER_ID, + make_contact, + make_group, + make_group_message, + make_member, +) + +CONFIG = Config( + display_name="Support", + db_prefix="./x", + welcome="hi", + group_name="Invite roster", + member_role="owner", +) + + +@pytest.fixture +def ctx(api): + # The bot always has its own marked roster group; reconcile checks for it. + api.groups.append( + make_group( + ROSTER_GROUP_ID, + {"displayName": "Invite roster", "fullName": ""}, + custom_data={"supportBotLight": {"group": "roster"}}, + ) + ) + return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG) + + +async def test_dm_with_existing_contact_marks_active(ctx, api): + api.contacts.append(make_contact(7, "sh", connected=True)) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "active" + assert api.replies == [messages.ADDED] + assert api.created_member_contacts == [] + + +async def test_dm_promotes_pending_contact_keeps_original_since(ctx, api): + # Self-heal after a missed contactConnected; the ask date must survive. + api.contacts.append( + make_contact( + 7, + "Alex", + {"supportBotLight": {"roster": "pending", "since": "2026-08-01T00:00:00+00:00"}}, + connected=True, + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data[-1][1]["supportBotLight"] == { + "roster": "active", + "since": "2026-08-01T00:00:00+00:00", + } + assert api.replies == [messages.ADDED] + + +async def test_dm_promotes_usable_contact_even_if_invitation_was_sent(ctx, api): + # The invitation is what made the contact usable, so both flags are set. + api.contacts.append( + make_contact( + 7, + "Alex", + {"supportBotLight": {"roster": "pending", "since": "x"}}, + connected=True, + grp_inv_sent=True, + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "active" + assert api.replies == [messages.ADDED] + + +async def test_dm_without_contact_creates_and_invites(ctx, api): + msg = make_group_message(api, make_member(1, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.created_member_contacts == [(ROSTER_GROUP_ID, 1)] + assert api.invitations == [(100, messages.INVITATION_TEXT)] + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending" + assert api.replies == [messages.INVITATION_SENT] + + +async def test_dm_replies_invitation_failed_when_send_fails(ctx, api): + api.fail_on.add("api_send_member_contact_invitation") + msg = make_group_message(api, make_member(1, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending" + assert api.replies == [messages.INVITATION_FAILED] + + +async def test_dm_while_pending_and_invitation_sent_is_a_noop(ctx, api): + # The core rejects a second invitation. + api.contacts.append( + make_contact( + 7, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}, grp_inv_sent=True + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.invitations == [] + assert api.custom_data == [] # already pending, mark untouched + assert api.replies == [messages.STILL_PENDING] + + +async def test_dm_while_pending_and_invitation_never_sent_resends_it(ctx, api): + api.contacts.append( + make_contact(7, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.invitations == [(7, messages.INVITATION_TEXT)] + assert api.custom_data == [] # already pending, mark untouched + assert api.replies == [messages.INVITATION_SENT] + + +async def test_dm_when_already_active_is_a_noop(ctx, api): + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data == [] + assert api.replies == [messages.ALREADY_ACTIVE] + + +async def test_dm_ignores_non_group_message(ctx, api): + msg = make_group_message(api, make_member(1), "/dm") + msg.chat_item["chatItem"]["chatDir"] = {"type": "directRcv"} + await handlers.dm(ctx, msg) + assert api.replies == [] and api.custom_data == [] + + +async def test_dm_recovers_when_contact_vanished(ctx, api): + # memberContactId points at a contact that no longer exists. + msg = make_group_message(api, make_member(1, contact_id=404, name="ghost"), "/dm") + await handlers.dm(ctx, msg) + assert api.created_member_contacts == [(ROSTER_GROUP_ID, 1)] + + +async def test_dm_replies_command_failed_when_api_fails(ctx, api): + # api_create_member_contact has no try/except of its own. + api.fail_on.add("api_create_member_contact") + msg = make_group_message(api, make_member(1, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.replies == [messages.COMMAND_FAILED] + + +async def test_dm_lets_unexpected_errors_propagate(ctx, api): + async def boom(contact_id, message=None): + raise RuntimeError("network on fire") + + api.api_send_member_contact_invitation = boom + msg = make_group_message(api, make_member(1, name="Alex"), "/dm") + with pytest.raises(RuntimeError): + await handlers.dm(ctx, msg) + + +async def test_dm_after_leave_does_not_promote_unconnected_contact(ctx, api): + member = make_member(1, name="Alex") + await handlers.dm(ctx, make_group_message(api, member, "/dm")) + assert api.created_member_contacts == [(ROSTER_GROUP_ID, 1)] + created_contact_id = api.contacts[-1]["contactId"] + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending" + + # The core sets memberContactId as soon as the contact exists. + member["memberContactId"] = created_contact_id + await handlers.leave(ctx, make_group_message(api, member, "/leave")) + api.custom_data.clear() + + await handlers.dm(ctx, make_group_message(api, member, "/dm")) + written = api.custom_data[-1][1]["supportBotLight"]["roster"] if api.custom_data else None + assert written != "active", "unconnected contact must never be marked active" + + +async def test_dm_after_leave_still_promotes_on_accept(ctx, api): + """/dm -> /leave -> /dm -> accept must end up active. + + /leave clears our roster mark, but the core's contactGrpInvSent survives and + cannot be unset, so the second /dm must re-establish the pending mark + or the eventual acceptance has nothing to promote. + """ + member = make_member(1, name="Alex") + await handlers.dm(ctx, make_group_message(api, member, "/dm")) + contact_id = api.contacts[-1]["contactId"] + # The fake names the contact after the group member id, not the member. + contact_name = api.contacts[-1]["profile"]["displayName"] + member["memberContactId"] = contact_id + + await handlers.leave(ctx, make_group_message(api, member, "/leave")) + await handlers.dm(ctx, make_group_message(api, member, "/dm")) + assert api.replies[-1] == messages.STILL_PENDING + + for c in api.contacts: + if c["contactId"] == contact_id: + c["activeConn"] = {"connStatus": {"type": "ready"}} + await handlers.contact_ready(ctx, contact_id) + assert [e.name for e in await roster.active(api, USER_ID)] == [contact_name] + + +async def test_contact_connected_promotes_pending(ctx, api): + api.contacts.append( + make_contact( + 7, + "Alex", + {"supportBotLight": {"roster": "pending", "since": "2026-08-13"}}, + connected=True, + ) + ) + await handlers.contact_ready(ctx, 7) + assert api.custom_data[-1][1]["supportBotLight"] == { + "roster": "active", + "since": "2026-08-13", # original ask time preserved + } + assert api.sent == [(["group", ROSTER_GROUP_ID], "Now on the roster: Alex")] + + +async def test_contact_connected_ignores_unmarked_contact(ctx, api): + api.contacts.append(make_contact(7, "stranger")) + await handlers.contact_ready(ctx, 7) + assert api.custom_data == [] and api.sent == [] + + +async def test_contact_connected_ignores_already_active(ctx, api): + api.contacts.append( + make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}) + ) + await handlers.contact_ready(ctx, 7) + assert api.custom_data == [] and api.sent == [] + + +async def test_contact_connected_does_not_promote_an_unusable_connection(ctx, api): + # The event says the connection is up; the contact record says otherwise. + # active() consults the record, so promoting here would list somebody the + # bot cannot reach. + api.contacts.append( + make_contact( + 7, + "Alex", + {"supportBotLight": {"roster": "pending", "since": "x"}}, + conn_status="deleted", + ) + ) + await handlers.contact_ready(ctx, 7) + assert api.custom_data == [] and api.sent == [] + + +async def test_contact_connected_for_unknown_contact_is_a_noop(ctx, api): + await handlers.contact_ready(ctx, 999) + assert api.custom_data == [] and api.sent == [] + + +async def test_a_failed_revocation_is_reported_to_the_roster_group(ctx, api): + # Revocation is the access-control path; a silent failure would leave the + # operator reading /list as the truth. + api.contacts.append( + make_contact(7, "Alex", {"supportBotLight": {"roster": "active", "since": "x"}}) + ) + api.fail_on.add("api_set_contact_custom_data") + await handlers.member_gone(ctx, ROSTER_GROUP_ID, make_member(1, contact_id=7, name="Alex")) + assert api.sent[-1][1] == messages.REVOKE_FAILED.format(name="Alex") + + +async def test_list_renders_both_states(ctx, api): + api.contacts += [ + make_contact( + 1, + "sh", + {"supportBotLight": {"roster": "active", "since": "2026-08-13"}}, + connected=True, + ), + make_contact(2, "Alex", {"supportBotLight": {"roster": "pending", "since": "2026-08-13"}}), + ] + await handlers.list_roster(ctx, make_group_message(api, make_member(1), "/list")) + assert "On the roster (1):" in api.replies[0] + assert "Contact request not accepted (1):" in api.replies[0] + + +async def test_list_when_empty(ctx, api): + await handlers.list_roster(ctx, make_group_message(api, make_member(1), "/list")) + assert api.replies == [messages.ROSTER_EMPTY] + + +async def test_list_replies_command_failed_when_api_fails(ctx, api): + api.fail_on.add("api_list_contacts") + await handlers.list_roster(ctx, make_group_message(api, make_member(1), "/list")) + assert api.replies == [messages.COMMAND_FAILED] + + +async def test_leave_clears_the_mark(ctx, api): + api.contacts.append( + make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/leave") + await handlers.leave(ctx, msg) + assert api.custom_data[-1] == (7, None) + assert api.replies == [messages.LEFT] + + +async def test_leave_when_not_on_roster(ctx, api): + api.contacts.append(make_contact(7, "sh")) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/leave") + await handlers.leave(ctx, msg) + assert api.custom_data == [] + assert api.replies == [messages.NOT_ON_ROSTER] + + +async def test_leave_without_any_contact(ctx, api): + msg = make_group_message(api, make_member(1, name="stranger"), "/leave") + await handlers.leave(ctx, msg) + assert api.replies == [messages.NOT_ON_ROSTER] + assert api.created_member_contacts == [] # /leave never creates a contact + + +async def test_leave_ignores_non_group_message(ctx, api): + msg = make_group_message(api, make_member(1, contact_id=7), "/leave") + msg.chat_item["chatItem"]["chatDir"] = {"type": "directRcv"} + await handlers.leave(ctx, msg) + assert api.replies == [] and api.custom_data == [] + + +async def test_leave_replies_command_failed_when_api_fails(ctx, api): + api.contacts.append( + make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}) + ) + api.fail_on.add("api_list_contacts") + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/leave") + await handlers.leave(ctx, msg) + assert api.replies == [messages.COMMAND_FAILED] + + +async def test_help_replies_with_help_text(ctx, api): + await handlers.help_cmd(ctx, make_group_message(api, make_member(1), "/help")) + assert api.replies == [messages.HELP] + + +async def test_help_replies_command_failed_when_send_fails(ctx, api): + # help_cmd's only action is the reply, so the first send must fail alone. + calls = 0 + original = api.api_send_text_reply + + async def flaky_once(chat_item, text): + nonlocal calls + calls += 1 + if calls == 1: + raise ChatCommandError("boom", {"type": "chatCmdError"}) + return await original(chat_item, text) + + api.api_send_text_reply = flaky_once + await handlers.help_cmd(ctx, make_group_message(api, make_member(1), "/help")) + assert api.replies == [messages.COMMAND_FAILED] + + +async def test_dm_redrives_a_contact_that_never_connected(ctx, api): + # Marked active but never usable: the member contact still exists, so the + # invitation can be re-sent. + api.contacts.append( + make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "2026-01-01"}}) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending" + assert api.invitations == [(7, messages.INVITATION_TEXT)] + assert api.replies == [messages.INVITATION_SENT] + + +async def test_dm_reports_a_connection_that_is_gone_for_good(ctx, api): + # The person deleted the bot after connecting. The core cleared + # contactGroupMemberId, so no invitation can be sent and telling them to + # retry would be false. + api.contacts.append( + make_contact( + 7, + "sh", + {"supportBotLight": {"roster": "active", "since": "2026-01-01"}}, + grp_member_id=None, + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.invitations == [] + assert api.replies == [messages.CONNECTION_LOST] + + +async def test_dm_on_a_dead_contact_with_an_invitation_outstanding_waits(ctx, api): + api.contacts.append( + make_contact( + 7, + "sh", + {"supportBotLight": {"roster": "active", "since": "2026-01-01"}}, + grp_inv_sent=True, + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending" + assert api.invitations == [] + assert api.replies == [messages.STILL_PENDING] + + +async def test_dm_finds_a_contact_created_since_the_message_was_built(ctx, api): + # Two commands sent in quick succession both carry the pre-/dm snapshot, in + # which memberContactId is still None. + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "pending", "since": "x"}}, grp_inv_sent=True + ) + ) + api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=7, name="sh")] + stale = make_member(1, name="sh") # no memberContactId + await handlers.dm(ctx, make_group_message(api, stale, "/dm")) + assert api.created_member_contacts == [] + assert api.replies == [messages.STILL_PENDING] + + +async def test_leave_finds_a_contact_created_since_the_message_was_built(ctx, api): + api.contacts.append( + make_contact(7, "sh", {"supportBotLight": {"roster": "pending", "since": "x"}}) + ) + api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=7, name="sh")] + stale = make_member(1, name="sh") + await handlers.leave(ctx, make_group_message(api, stale, "/leave")) + assert api.custom_data[-1] == (7, None) + assert api.replies == [messages.LEFT] + + +async def test_member_gone_takes_them_off_the_roster(ctx, api): + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + await handlers.member_gone(ctx, ROSTER_GROUP_ID, make_member(1, contact_id=7, name="sh")) + assert api.custom_data[-1] == (7, None) + assert api.sent[-1][1] == messages.REMOVED_FROM_GROUP.format(name="sh") + + +async def test_member_gone_ignores_other_groups(ctx, api): + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + await handlers.member_gone(ctx, 999, make_member(1, contact_id=7, name="sh")) + assert api.custom_data == [] and api.sent == [] + + +async def test_member_gone_ignores_someone_not_on_the_roster(ctx, api): + api.contacts.append(make_contact(7, "sh", connected=True)) + await handlers.member_gone(ctx, ROSTER_GROUP_ID, make_member(1, contact_id=7, name="sh")) + assert api.custom_data == [] and api.sent == [] + + +async def test_reconcile_promotes_an_acceptance_missed_while_stopped(ctx, api): + api.members[ROSTER_GROUP_ID] = [ + make_member(1, contact_id=1), + make_member(2, contact_id=2), + make_member(3, contact_id=3), + ] + api.contacts += [ + make_contact( + 1, + "accepted", + {"supportBotLight": {"roster": "pending", "since": "2026-01-01"}}, + connected=True, + ), + make_contact( + 2, "waiting", {"supportBotLight": {"roster": "pending", "since": "2026-01-01"}} + ), + make_contact( + 3, + "already", + {"supportBotLight": {"roster": "active", "since": "2026-01-01"}}, + connected=True, + ), + ] + await handlers.reconcile_roster(ctx) + assert api.custom_data == [ + (1, {"supportBotLight": {"roster": "active", "since": "2026-01-01"}}) + ] + assert api.sent[-1][1] == messages.NOW_ACTIVE.format(name="accepted") + + +async def test_contact_ready_failure_does_not_escape(ctx, api): + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "pending", "since": "x"}}, connected=True + ) + ) + api.fail_on.add("api_set_contact_custom_data") + await handlers.contact_ready(ctx, 7) # must not raise + assert api.sent == [] + + +async def test_reconcile_removes_someone_who_left_while_stopped(ctx, api): + # api_list_members keeps the row and only changes its status. + api.members[ROSTER_GROUP_ID] = [ + make_member(1, contact_id=5, name="stays"), + make_member(2, contact_id=7, name="gone", status="left"), + ] + api.contacts += [ + make_contact( + 5, "stays", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact( + 7, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + ] + await handlers.reconcile_roster(ctx) + assert api.custom_data[-1] == (7, None) + assert api.sent[-1][1] == messages.REMOVED_FROM_GROUP.format(name="gone") + + +async def test_reconcile_failure_does_not_stop_startup(ctx, api): + api.fail_on.add("api_list_members") + await handlers.reconcile_roster(ctx) # must not raise + + +async def test_reconcile_continues_past_a_failing_contact(ctx, api): + api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=5, name="stays")] + api.contacts += [ + make_contact( + 7, "a", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact( + 8, "b", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + ] + attempts: list[int] = [] + + async def flaky(contact_id, custom_data=None): + attempts.append(contact_id) + raise ChatCommandError("nope", {"type": "chatCmdError"}) + + api.api_set_contact_custom_data = flaky + await handlers.reconcile_roster(ctx) + assert attempts == [7, 8], "a failure on one contact must not abandon the rest" + + +async def test_dm_on_a_dead_contact_leaves_the_mark_alone(ctx, api): + api.contacts.append( + make_contact( + 7, + "sh", + {"supportBotLight": {"roster": "active", "since": "2026-01-01"}}, + grp_member_id=None, + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data == [] + assert api.replies == [messages.CONNECTION_LOST] + + +async def test_reconcile_skips_revocation_when_the_marker_is_ambiguous(ctx, api): + # A second marked group means ensure_roster_group may have picked the wrong + # one; deleting every mark on that basis is not recoverable. + api.groups.append( + make_group( + 99, + {"displayName": "Invite roster", "fullName": ""}, + custom_data={"supportBotLight": {"group": "roster"}}, + ) + ) + api.members[ROSTER_GROUP_ID] = [] + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + await handlers.reconcile_roster(ctx) + assert api.custom_data == [] + + +async def test_reconcile_revokes_even_when_the_last_member_leaves(ctx, api): + api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=7, name="gone", status="left")] + api.contacts.append( + make_contact( + 7, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + await handlers.reconcile_roster(ctx) + assert api.custom_data[-1] == (7, None) + + +async def test_dm_accepts_a_connection_the_member_started(ctx, api): + # Tapping "connect directly" on the bot's profile leaves a prepared contact + # with no contactGroupMemberId, which looks identical to a dead one. + api.contacts.append(make_contact(7, "Kit", grp_member_id=None, conn_status="prepared")) + msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm") + await handlers.dm(ctx, msg) + assert api.accepted_member_contacts == [7] + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending" + assert api.replies == [messages.ACCEPTING] + + +async def test_dm_still_reports_a_genuinely_dead_contact(ctx, api): + # No prepared connection and no groupDirectInv: nothing to accept. + api.contacts.append(make_contact(7, "sh", grp_member_id=None, conn_status="deleted")) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.accepted_member_contacts == [] + assert api.replies == [messages.CONNECTION_LOST] + + +async def test_dm_does_not_re_accept_a_connection_already_started(ctx, api): + # The core keeps groupDirectInv after acceptance and rejects a second + # accept with "connection already started". + contact = make_contact(7, "Kit", grp_member_id=None, conn_status="prepared") + contact["groupDirectInv"] = { + "groupDirectInvLink": "x", + "groupDirectInvStartedConnection": True, + } + api.contacts.append(contact) + msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm") + await handlers.dm(ctx, msg) + assert api.accepted_member_contacts == [] + assert api.replies == [messages.ACCEPTING] + + +async def test_dm_accepts_an_invitation_not_yet_started(ctx, api): + contact = make_contact(7, "Kit", grp_member_id=None, conn_status="prepared") + contact["groupDirectInv"] = { + "groupDirectInvLink": "x", + "groupDirectInvStartedConnection": False, + } + api.contacts.append(contact) + msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm") + await handlers.dm(ctx, msg) + assert api.accepted_member_contacts == [7] + + +async def test_dm_reports_a_handshake_in_progress_as_connecting(ctx, api): + # The core clears contactGroupMemberId when the peer accepts and only later + # reports ready. Calling that gone would send the member to advice that + # tears the completing connection down. + api.contacts.append( + make_contact( + 7, + "Kit", + {"supportBotLight": {"roster": "pending", "since": "x"}}, + grp_member_id=None, + conn_status="accepted", + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm") + await handlers.dm(ctx, msg) + assert api.replies == [messages.CONNECTING] + assert api.accepted_member_contacts == [] + + +async def test_dm_marks_an_unmarked_member_whose_connection_is_completing(ctx, api): + # ACCEPTING and CONNECTING both promise a roster place, and contact_ready + # delivers it only for a pending mark. + api.contacts.append(make_contact(7, "Kit", grp_member_id=None, conn_status="accepted")) + msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm") + await handlers.dm(ctx, msg) + assert roster.entry_of(api.contacts[0]) is not None + + for c in api.contacts: + c["activeConn"] = {"connStatus": {"type": "ready"}} + await handlers.contact_ready(ctx, 7) + assert [e.name for e in await roster.active(api, USER_ID)] == ["Kit"] + + +async def test_dm_marks_an_unmarked_member_whose_accept_already_started(ctx, api): + contact = make_contact(7, "Kit", grp_member_id=None, conn_status="joined") + contact["groupDirectInv"] = { + "groupDirectInvLink": "x", + "groupDirectInvStartedConnection": True, + } + api.contacts.append(contact) + msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm") + await handlers.dm(ctx, msg) + assert api.replies == [messages.ACCEPTING] + assert roster.entry_of(api.contacts[0]) is not None + + +async def test_dm_reports_a_dead_connection_even_after_we_accepted(ctx, api): + # The core never clears groupDirectInv, so the started flag alone would + # promise progress on a connection the peer has since deleted. + contact = make_contact(7, "Alice", grp_member_id=None, conn_status="deleted") + contact["groupDirectInv"] = { + "groupDirectInvLink": "x", + "groupDirectInvStartedConnection": True, + } + api.contacts.append(contact) + msg = make_group_message(api, make_member(1, contact_id=7, name="Alice"), "/dm") + await handlers.dm(ctx, msg) + assert api.accepted_member_contacts == [] + assert api.replies == [messages.CONNECTION_LOST] + + +async def test_dm_fast_path_announces_the_arrival(ctx, api): + api.contacts.append(make_contact(7, "sh", connected=True)) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.replies == [messages.ADDED] + assert api.sent[-1][1] == messages.NOW_ACTIVE.format(name="sh") + + +async def test_dm_on_an_already_active_member_announces_nothing(ctx, api): + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.sent == [] diff --git a/apps/simplex-support-bot-light/tests/test_health.py b/apps/simplex-support-bot-light/tests/test_health.py new file mode 100644 index 0000000000..d818465175 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_health.py @@ -0,0 +1,236 @@ +"""The monitoring endpoint: real sockets, no libsimplex.""" + +import asyncio + +import pytest +from simplex_chat.core import ChatAPIError + +from support_bot_light import health +from support_bot_light.config import Config, ConfigError, Health +from support_bot_light.context import BotContext +from tests.conftest import ROSTER_GROUP_ID, USER_ID + +CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner") + + +class ProbeApi: + """The one call the probe makes, with the outcomes it has to distinguish.""" + + def __init__(self, error: bool = False, delay: float = 0.0): + self.error = error + self.delay = delay + self.calls = 0 + + async def api_list_members(self, group_id: int) -> list[dict]: + self.calls += 1 + assert group_id == ROSTER_GROUP_ID + if self.delay: + await asyncio.sleep(self.delay) + if self.error: + raise ChatAPIError("core is unhappy", {"type": "chatCmdError"}) + return [] + + +def context(api) -> BotContext: + return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG) + + +async def request(server: asyncio.Server, line: str) -> str: + """Send one request line to a running endpoint and read the whole reply.""" + port = server.sockets[0].getsockname()[1] + reader, writer = await asyncio.open_connection("127.0.0.1", port) + try: + writer.write(f"{line}\r\nHost: localhost\r\n\r\n".encode()) + await writer.drain() + return (await reader.read()).decode("latin-1") + finally: + writer.close() + await writer.wait_closed() + + +async def endpoint(api) -> asyncio.Server: + # Port 0: the OS picks a free one, so tests never collide. + return await health.serve(context(api), Health(host="127.0.0.1", port=0)) + + +async def test_reports_ok_while_the_core_answers(): + api = ProbeApi() + server = await endpoint(api) + try: + reply = await request(server, "GET /health HTTP/1.1") + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 200 OK") + assert reply.endswith('{"status":"ok"}\n') + assert api.calls == 1 + + +async def test_reports_unavailable_when_the_core_errors(): + server = await endpoint(ProbeApi(error=True)) + try: + reply = await request(server, "GET /health HTTP/1.1") + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 503 Service Unavailable") + + +async def test_a_slow_core_times_out_rather_than_hanging(monkeypatch): + monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05) + api = ProbeApi(delay=5) + server = await endpoint(api) + try: + reply = await asyncio.wait_for(request(server, "GET /health HTTP/1.1"), 2) + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 503") + + +async def test_a_concurrent_request_does_not_start_a_second_probe(monkeypatch): + monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.5) + api = ProbeApi(delay=0.3) + server = await endpoint(api) + try: + first = asyncio.create_task(request(server, "GET /health HTTP/1.1")) + await asyncio.sleep(0.05) + second = await request(server, "GET /health HTTP/1.1") + assert (await first).startswith("HTTP/1.1 200 OK") + finally: + server.close() + await server.wait_closed() + assert second.startswith("HTTP/1.1 200 OK") # it waits on the same probe + assert api.calls == 1 + + +async def test_polling_a_stalled_core_never_starts_a_second_query(monkeypatch): + # Each abandoned query keeps a worker in the loop's default executor, which + # the receive loop also uses: a query per poll would take the bot's own + # traffic down with the core. + monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05) + api = ProbeApi(delay=3) + server = await endpoint(api) + try: + for _ in range(5): + reply = await request(server, "GET /health HTTP/1.1") + assert reply.startswith("HTTP/1.1 503") + assert api.calls == 1 # one query outstanding, not five + finally: + server.close() + await server.wait_closed() + + +async def test_the_next_poll_after_recovery_starts_a_fresh_query(monkeypatch): + monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05) + api = ProbeApi(delay=0.2) + server = await endpoint(api) + try: + assert (await request(server, "GET /health HTTP/1.1")).startswith("HTTP/1.1 503") + await asyncio.sleep(0.3) # the abandoned query completes + api.delay = 0 # the core recovers + assert (await request(server, "GET /health HTTP/1.1")).startswith("HTTP/1.1 200") + finally: + server.close() + await server.wait_closed() + assert api.calls == 2 + + +async def test_a_core_that_raises_anything_reports_unavailable(): + # A malformed reply or a missing controller is what this exists to report, + # and neither arrives as a chat error. + class Broken(ProbeApi): + async def api_list_members(self, group_id: int) -> list[dict]: + self.calls += 1 + raise RuntimeError("controller not initialized") + + api = Broken() + server = await endpoint(api) + try: + reply = await request(server, "GET /health HTTP/1.1") + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 503") + + +async def test_an_oversized_request_line_is_answered(): + api = ProbeApi() + server = await endpoint(api) + try: + reply = await request(server, "GET /" + "x" * (health.MAX_REQUEST_BYTES + 10)) + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 400") + assert api.calls == 0 + + +async def test_head_is_answered_without_a_body(): + api = ProbeApi() + server = await endpoint(api) + try: + reply = await request(server, "HEAD /health HTTP/1.1") + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 200 OK") + assert "{" not in reply + assert api.calls == 1 + + +@pytest.mark.parametrize( + ("line", "status"), + [ + ("GET / HTTP/1.1", "404"), + ("GET /healthz HTTP/1.1", "404"), + ("POST /health HTTP/1.1", "405"), + ("nonsense", "404"), + ], +) +async def test_only_get_on_the_health_path_is_answered(line, status): + api = ProbeApi() + server = await endpoint(api) + try: + reply = await request(server, line) + finally: + server.close() + await server.wait_closed() + assert reply.startswith(f"HTTP/1.1 {status}") + assert api.calls == 0 + + +async def test_a_query_string_still_matches_the_path(): + api = ProbeApi() + server = await endpoint(api) + try: + reply = await request(server, "GET /health?from=monitor HTTP/1.1") + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 200 OK") + + +async def test_a_configured_port_already_in_use_stops_the_bot(): + api = ProbeApi() + taken = await endpoint(api) + port = taken.sockets[0].getsockname()[1] + try: + with pytest.raises(ConfigError, match="cannot listen"): + await health.serve(context(api), Health("127.0.0.1", port, configured=True)) + finally: + taken.close() + await taken.wait_closed() + + +async def test_the_default_port_being_in_use_does_not_stop_the_bot(): + # Nothing asked for port 8080; an unrelated service on it is not a reason to + # refuse to answer chats. + api = ProbeApi() + taken = await endpoint(api) + port = taken.sockets[0].getsockname()[1] + try: + assert await health.serve(context(api), Health("127.0.0.1", port)) is None + finally: + taken.close() + await taken.wait_closed() diff --git a/apps/simplex-support-bot-light/tests/test_main.py b/apps/simplex-support-bot-light/tests/test_main.py new file mode 100644 index 0000000000..c49b01dbcb --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_main.py @@ -0,0 +1,312 @@ +import pytest +from simplex_chat import Bot, BotProfile, SqliteDb +from simplex_chat.core import ChatAPIError + +from support_bot_light import handlers, health +from support_bot_light.__main__ import ( + _register, + _run, + _serve, + bot_profile, + build_bot, + startup_error, +) +from support_bot_light.config import Config, Health +from support_bot_light.context import BotContext +from tests.conftest import ( + ROSTER_GROUP_ID, + USER_ID, + make_group, + make_group_message, + make_member, +) + +CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner") +OTHER_GROUP_ID = 99 + + +def plain_bot() -> Bot: + return Bot( + profile=BotProfile(display_name="Support"), + db=SqliteDb(file_prefix="./unused"), + welcome="hi", + ) + + +def registered(api) -> Bot: + bot = plain_bot() + ctx = BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG) + _register(bot, ctx) + return bot + + +def test_registers_all_four_commands(api): + bot = registered(api) + keywords = [names for names, _predicate, _handler in bot._command_handlers] + assert keywords == [("dm",), ("list",), ("leave",), ("help",)] + + +def test_registers_connection_and_business_events(api): + bot = registered(api) + assert set(bot._event_handlers) == { + "acceptingBusinessRequest", + "contactConnected", + "contactSndReady", + "deletedMember", + "leftMember", + } + + +def test_commands_match_in_the_roster_group(api): + bot = registered(api) + msg = make_group_message(api, make_member(1), "/dm", group_id=ROSTER_GROUP_ID) + _names, predicate, _handler = bot._command_handlers[0] + assert predicate(msg) is True + + +def test_commands_do_not_match_in_other_groups(api): + # A /dm typed inside a business chat must not be acted on. + bot = registered(api) + msg = make_group_message(api, make_member(1), "/dm", group_id=OTHER_GROUP_ID) + _names, predicate, _handler = bot._command_handlers[0] + assert predicate(msg) is False + + +def test_bot_profile_carries_display_name_and_image(): + profile = bot_profile( + Config("Support", "./x", "hi", "R", "owner", image="data:image/png;base64,AAA") + ) + assert profile.display_name == "Support" + assert profile.image == "data:image/png;base64,AAA" + + +def test_bot_profile_without_image(): + assert bot_profile(CONFIG).image is None + + +@pytest.mark.parametrize("index,keyword", [(0, "dm"), (1, "list"), (2, "leave"), (3, "help")]) +def test_every_command_is_scoped_to_the_roster_group(api, index, keyword): + # A /list answered in a business chat would show the roster to a customer. + bot = registered(api) + names, predicate, _handler = bot._command_handlers[index] + assert names == (keyword,) + inside = make_group_message(api, make_member(1), f"/{keyword}", group_id=ROSTER_GROUP_ID) + outside = make_group_message(api, make_member(1), f"/{keyword}", group_id=OTHER_GROUP_ID) + assert predicate(inside) is True + assert predicate(outside) is False + + +async def test_registered_handlers_call_the_matching_handler(api, monkeypatch): + # Registration bookkeeping alone would not catch /dm being wired to leave(). + bot = registered(api) + called: list[str] = [] + + def spy(name): + async def handler(_ctx, _msg): + called.append(name) + + return handler + + for name in ("dm", "list_roster", "leave", "help_cmd"): + monkeypatch.setattr(handlers, name, spy(name)) + for keywords, _predicate, handler in bot._command_handlers: + await handler(make_group_message(api, make_member(1), f"/{keywords[0]}"), None) + assert called == ["dm", "list_roster", "leave", "help_cmd"] + + +def test_a_taken_display_name_is_explained(): + # The core reports it as a bare errorStore; the cause is in the store error. + e = ChatAPIError("chat command error: errorStore", {"storeError": {"type": "duplicateName"}}) + assert "bot.display_name" in startup_error(e) + + +def test_any_other_chat_error_keeps_its_detail(): + e = ChatAPIError("chat command error: errorStore", {"storeError": {"type": "userNotFound"}}) + assert "userNotFound" in startup_error(e) + + +def test_a_rejected_command_is_quoted_as_the_core_wrote_it(): + # The core puts what the caller did wrong in the message, and the tag says + # nothing; printing the raw dict instead would bury it. + e = ChatAPIError( + "chat command error: error", + {"type": "error", "errorType": {"type": "commandError", "message": "Profile image"}}, + ) + assert startup_error(e) == "Profile image" + + +def test_an_error_without_detail_is_rendered_plainly(): + assert startup_error(ValueError("no active user after start")) == "no active user after start" + + +def test_the_bot_opens_a_business_address(): + # Without these two the address yields direct chats that nothing handles: + # acceptingBusinessRequest never fires and no roster is ever added. + bot = build_bot(CONFIG) + assert bot._business_address is True + assert bot._auto_accept is True + assert bot._welcome == "hi" + + +def test_the_bot_does_not_apply_its_profile_while_starting(): + # The name the core will accept is only knowable from the database, which + # nothing can read until the client has started. _apply_profile does it. + assert build_bot(CONFIG)._update_profile is False + + +class FakeBot: + """A Bot stand-in for _serve: an async context manager with an api.""" + + def __init__(self, api, sync_error: Exception | None = None): + self.api = api + self.profile = BotProfile(display_name="Support") + self.served = 0 + self.syncs = 0 + self.sync_error = sync_error + self.signal_handlers = 0 + self._command_handlers = [] + self._event_handlers = {} + self.stop_requested = False + self.stopped = False + + async def __aenter__(self): + return self + + async def __aexit__(self, *_exc): + return False + + def install_signal_handlers(self): + self.signal_handlers += 1 + + async def sync_profile(self) -> bool: + self.syncs += 1 + if self.sync_error is not None: + raise self.sync_error + return True + + def on_command(self, *_names, **_kw): + def register(handler): + self._command_handlers.append(handler) + return handler + + return register + + def on_event(self, tag): + def register(handler): + self._event_handlers.setdefault(tag, []).append(handler) + return handler + + return register + + async def serve_forever(self): + self.served += 1 + + def stop(self): + self.stopped = True + + +def serve_api(api): + """The fake api with the calls _serve makes before serving.""" + + async def api_get_active_user(): + return {"userId": USER_ID, "localDisplayName": "Support"} + + api.api_get_active_user = api_get_active_user + api.group_links[ROSTER_GROUP_ID] = "https://example.invalid/g#x" + api.groups.append( + make_group( + ROSTER_GROUP_ID, + {"displayName": "Invite roster", "fullName": ""}, + {"supportBotLight": {"group": "roster"}}, + ) + ) + return api + + +async def test_serve_wires_the_handlers_and_serves(api): + bot = FakeBot(serve_api(api)) + await _serve(CONFIG, bot) + assert bot.served == 1 + assert len(bot._command_handlers) == 4 # nothing is delivered without these + assert set(bot._event_handlers) == { + "acceptingBusinessRequest", + "contactConnected", + "contactSndReady", + "deletedMember", + "leftMember", + } + + +async def test_serve_reads_the_group_listing_once(api): + # It is the largest thing startup marshals and grows with every customer. + bot = FakeBot(serve_api(api)) + calls = {"n": 0} + original = api.api_list_groups + + async def counted(user_id, **kw): + calls["n"] += 1 + return await original(user_id, **kw) + + api.api_list_groups = counted + await _serve(CONFIG, bot) + assert calls["n"] == 2 # one for discovery, one shared by both passes + + +async def test_serve_does_not_begin_serving_after_a_signal(api): + bot = FakeBot(serve_api(api)) + bot.stop_requested = True + await _serve(CONFIG, bot) + assert bot.served == 0 + + +async def test_serve_closes_the_health_endpoint_afterwards(api): + config = Config("Support", "./x", "hi", "Invite roster", "owner", health=Health("127.0.0.1", 0)) + bot = FakeBot(serve_api(api)) + servers: list = [] + original = health.serve + + async def spy(ctx, cfg): + server = await original(ctx, cfg) + servers.append(server) + return server + + health.serve = spy + try: + await _serve(config, bot) + finally: + health.serve = original + assert servers and not servers[0].is_serving() + + +async def test_the_bot_serves_after_a_refused_rename(api, caplog): + # The core keeps display names unique; a refused one is not a reason to + # leave customers unanswered. + refused = ChatAPIError("x", {"storeError": {"type": "duplicateName"}}) + bot = FakeBot(serve_api(api), sync_error=refused) + await _serve(CONFIG, bot) + assert bot.served == 1 + assert "bot.display_name" in caplog.text + + +async def test_the_profile_is_applied_after_start(api, monkeypatch): + bot = FakeBot(serve_api(api)) + await _serve(CONFIG, bot) + assert bot.syncs == 1 + + +async def test_run_installs_signal_handlers_before_starting(monkeypatch): + # Startup runs migrations and address creation; a signal there would + # otherwise kill the process mid-write. + order: list[str] = [] + bot = FakeBot(None) + + def build(_config): + return bot + + async def serve(_config, b): + order.append(f"serve:{b.signal_handlers}") + + monkeypatch.setattr("support_bot_light.__main__.build_bot", build) + monkeypatch.setattr("support_bot_light.__main__._serve", serve) + await _run(CONFIG) + assert order == ["serve:1"] diff --git a/apps/simplex-support-bot-light/tests/test_messages.py b/apps/simplex-support-bot-light/tests/test_messages.py new file mode 100644 index 0000000000..e9b73ab8af --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_messages.py @@ -0,0 +1,85 @@ +from support_bot_light import messages +from support_bot_light.roster import RosterEntry + + +def entry(name, state, since="2026-08-13T09:00:00+00:00", reachable=True): + return RosterEntry(contact_id=1, name=name, state=state, since=since, reachable=reachable) + + +def test_render_roster_lists_active_and_pending(): + out = messages.render_roster([entry("sh", "active"), entry("Alex", "pending")]) + assert "On the roster (1):" in out + assert "• sh — since 2026-08-13" in out + assert "Contact request not accepted (1):" in out + assert "• Alex — asked 2026-08-13" in out + + +def test_render_roster_empty(): + assert messages.render_roster([]) == messages.ROSTER_EMPTY + + +def test_render_roster_omits_pending_section_when_none(): + out = messages.render_roster([entry("sh", "active")]) + assert "Waiting" not in out + + +def test_render_roster_omits_date_suffix_when_since_is_empty(): + out = messages.render_roster([entry("sh", "active", since="")]) + assert out == "On the roster (1):\n • sh" + assert "since" not in out + + +def test_render_roster_formats_date_suffix(): + out = messages.render_roster([entry("sh", "active")]) + assert out == "On the roster (1):\n • sh — since 2026-08-13" + + +def test_invite_log_lists_added_names(): + assert messages.invite_log("Alex", ["sh", "Narasimha"], []) == ( + "Connected: Alex → added sh, Narasimha" + ) + + +def test_invite_log_reports_failures(): + line = messages.invite_log("Alex", ["sh"], ["Narasimha"]) + assert line == "Connected: Alex → added sh (failed: Narasimha)" + + +def test_help_mentions_every_command(): + for keyword in ("dm", "list", "leave"): + assert f"/{keyword}" in messages.HELP + + +def test_render_roster_separates_unreachable_members(): + out = messages.render_roster( + [entry("live", "active"), entry("dead", "active", reachable=False)] + ) + assert "On the roster (1):" in out + assert "Not reachable, not being added (1):" in out + assert "• dead" in out + + +def test_render_roster_caps_long_sections(): + entries = [entry(f"n{i}", "active") for i in range(messages.MAX_LISTED + 12)] + out = messages.render_roster(entries) + assert f"On the roster ({messages.MAX_LISTED + 12}):" in out + assert "… and 12 more" in out + assert out.count("•") == messages.MAX_LISTED + assert len(out.encode()) < 15000 + + +def test_render_roster_bounds_the_whole_reply_in_bytes(): + # Names are capped in characters, so CJK can overrun a byte limit even with + # every section capped. + entries = [entry("漢" * 50, "active") for _ in range(messages.MAX_LISTED)] + entries += [entry("漢" * 50, "pending") for _ in range(messages.MAX_LISTED)] + out = messages.render_roster(entries) + assert len(out.encode()) <= messages.MAX_REPLY_BYTES + assert out.endswith(messages.TRUNCATED) + + +def test_invite_log_is_bounded_in_bytes(): + names = ["漢" * 50 for _ in range(100)] + out = messages.invite_log("Alex", names, []) + assert len(out.encode()) <= messages.MAX_REPLY_BYTES + assert out.endswith(messages.TRUNCATED) diff --git a/apps/simplex-support-bot-light/tests/test_roster.py b/apps/simplex-support-bot-light/tests/test_roster.py new file mode 100644 index 0000000000..a4dd17f2b3 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_roster.py @@ -0,0 +1,154 @@ +from support_bot_light import roster +from tests.conftest import USER_ID, make_contact + + +def test_entry_of_reads_active_mark(): + contact = make_contact( + 7, "sh", {"supportBotLight": {"roster": "active", "since": "2026-08-13"}} + ) + entry = roster.entry_of(contact) + assert entry is not None + assert (entry.contact_id, entry.name, entry.state, entry.since) == ( + 7, + "sh", + "active", + "2026-08-13", + ) + + +def test_entry_of_returns_none_without_custom_data(): + assert roster.entry_of(make_contact(7, "sh")) is None + + +def test_entry_of_ignores_other_namespaces(): + assert roster.entry_of(make_contact(7, "sh", {"otherBot": {"roster": "active"}})) is None + + +def test_entry_of_ignores_unknown_state(): + contact = make_contact(7, "sh", {"supportBotLight": {"roster": "banned"}}) + assert roster.entry_of(contact) is None + + +def test_entry_of_ignores_non_dict_mark(): + assert roster.entry_of(make_contact(7, "sh", {"supportBotLight": "oops"})) is None + + +async def test_mark_preserves_other_keys(api): + contact = make_contact(7, "sh", {"otherBot": {"keep": 1}}) + api.contacts.append(contact) + await roster.mark(api, contact, "active", "2026-08-13T09:00:00+00:00") + contact_id, data = api.custom_data[-1] + assert contact_id == 7 + assert data["otherBot"] == {"keep": 1} + assert data["supportBotLight"] == {"roster": "active", "since": "2026-08-13T09:00:00+00:00"} + + +async def test_mark_does_not_mutate_the_callers_contact(api): + original = {"otherBot": {"keep": 1}} + contact = make_contact(7, "sh", original) + api.contacts.append(contact) + await roster.mark(api, contact, "active", "2026-08-13T09:00:00+00:00") + # mark() builds a new blob; the caller's dict must be untouched. + assert original == {"otherBot": {"keep": 1}} + assert "supportBotLight" not in original + + +async def test_unmark_removes_only_our_key(api): + contact = make_contact( + 7, "sh", {"supportBotLight": {"roster": "active"}, "otherBot": {"keep": 1}} + ) + api.contacts.append(contact) + await roster.unmark(api, contact) + assert api.custom_data[-1] == (7, {"otherBot": {"keep": 1}}) + + +async def test_unmark_clears_blob_when_nothing_left(api): + contact = make_contact(7, "sh", {"supportBotLight": {"roster": "active"}}) + api.contacts.append(contact) + await roster.unmark(api, contact) + # None clears the column rather than writing an empty object. + assert api.custom_data[-1] == (7, None) + + +async def test_unmark_of_an_unmarked_contact_takes_nothing_away(api): + contact = make_contact(7, "sh", {"otherBot": {"keep": 1}}) + api.contacts.append(contact) + await roster.unmark(api, contact) + assert api.custom_data[-1] == (7, {"otherBot": {"keep": 1}}) + + +async def test_load_returns_marked_contacts_sorted_case_insensitively(api): + api.contacts += [ + make_contact(1, "Zoe", {"supportBotLight": {"roster": "active", "since": "x"}}), + make_contact(2, "bob", {"supportBotLight": {"roster": "pending", "since": "x"}}), + make_contact(3, "unmarked"), + ] + entries = await roster.load(api, USER_ID) + assert [e.name for e in entries] == ["bob", "Zoe"] + + +async def test_active_filters_pending(api): + api.contacts += [ + make_contact( + 1, "a", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact(2, "b", {"supportBotLight": {"roster": "pending", "since": "x"}}), + ] + assert [e.contact_id for e in await roster.active(api, USER_ID)] == [1] + + +async def test_find_contact_returns_none_when_absent(api): + assert await roster.find_contact(api, USER_ID, 99) is None + + +async def test_find_contact_returns_match(api): + api.contacts.append(make_contact(7, "sh")) + found = await roster.find_contact(api, USER_ID, 7) + assert found is not None and found["contactId"] == 7 + + +def test_utc_now_is_iso_with_offset(): + now = roster.utc_now() + assert now.endswith("+00:00") and "T" in now + + +async def test_active_excludes_a_marked_contact_that_is_no_longer_usable(api): + # The person deleted the bot: the mark survives but api_add_member would + # fail for them on every business chat. + api.contacts += [ + make_contact( + 1, "live", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact(2, "dead", {"supportBotLight": {"roster": "active", "since": "x"}}), + ] + assert [e.contact_id for e in await roster.active(api, USER_ID)] == [1] + + +def test_entry_name_is_sanitised(): + contact = make_contact(7, "a\nb", {"supportBotLight": {"roster": "active", "since": "x"}}) + entry = roster.entry_of(contact) + assert entry is not None + assert entry.name == "a b" + + +def test_entry_of_survives_a_contact_with_no_profile(): + entry = roster.entry_of( + {"contactId": 7, "customData": {"supportBotLight": {"roster": "active", "since": "x"}}} + ) + assert entry is not None + assert entry.name == "(unnamed)" + + +def test_contact_name_prefers_the_local_display_name(): + # The core makes localDisplayName unique per user; two peers calling + # themselves "sh" render as "sh" and "sh_1", which is what the roster and + # the log must show. + contact = make_contact(1, "sh_1") + contact["profile"]["displayName"] = "sh" + assert roster.contact_name(contact) == "sh_1" + + +def test_contact_name_falls_back_to_the_profile(): + contact = make_contact(1, "sh") + del contact["localDisplayName"] + assert roster.contact_name(contact) == "sh" diff --git a/apps/simplex-support-bot-light/tests/test_setup.py b/apps/simplex-support-bot-light/tests/test_setup.py new file mode 100644 index 0000000000..41c383478f --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_setup.py @@ -0,0 +1,224 @@ +import asyncio +import logging + +from support_bot_light import commands, setup +from support_bot_light.config import Config +from tests.conftest import ROSTER_GROUP_ID, USER_ID, make_group + +CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner") +MARKER = {"supportBotLight": {"group": "roster"}} + + +async def test_creates_group_when_none_marked(api, caplog): + caplog.set_level(logging.INFO) + group_id = await setup.ensure_roster_group(api, USER_ID, CONFIG) + assert group_id == ROSTER_GROUP_ID + profile = api.new_groups[0] + assert profile["displayName"] == "Invite roster" + assert profile["groupPreferences"]["directMessages"] == {"enable": "on"} + assert profile["groupPreferences"]["commands"] == commands.to_wire(commands.COMMANDS) + assert api.group_custom_data == [(ROSTER_GROUP_ID, MARKER)] + assert api.links == [ROSTER_GROUP_ID] # created exactly once + assert api.group_links[ROSTER_GROUP_ID] in caplog.text + + +async def test_finds_existing_group_by_marker(api): + profile = { + "displayName": "renamed by a human", + "fullName": "", + "groupPreferences": { + "directMessages": {"enable": "on"}, + "commands": commands.to_wire(commands.COMMANDS), + }, + } + api.groups.append(make_group(77, profile, custom_data=MARKER)) + api.group_links[77] = "https://simplex.chat/contact#/?v=2&group=77" + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77 + assert api.new_groups == [] # not recreated + assert api.profile_updates == [] # commands already match — no broadcast + + +async def test_found_group_with_link_logs_it_without_recreating(api, caplog): + caplog.set_level(logging.INFO) + profile = { + "displayName": "Invite roster", + "fullName": "", + "groupPreferences": { + "directMessages": {"enable": "on"}, + "commands": commands.to_wire(commands.COMMANDS), + }, + } + api.groups.append(make_group(77, profile, custom_data=MARKER)) + api.group_links[77] = "https://simplex.chat/contact#/?v=2&group=77" + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77 + assert api.links == [] # fetched, not recreated + assert "https://simplex.chat/contact#/?v=2&group=77" in caplog.text + + +async def test_found_group_without_link_recreates_and_logs_it(api, caplog): + # Crash window between marking the group and creating its link. + caplog.set_level(logging.INFO) + profile = { + "displayName": "Invite roster", + "fullName": "", + "groupPreferences": { + "directMessages": {"enable": "on"}, + "commands": commands.to_wire(commands.COMMANDS), + }, + } + api.groups.append(make_group(77, profile, custom_data=MARKER)) + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77 + assert api.links == [77] # recovered by creating a new link + assert api.group_links[77] in caplog.text + + +async def test_group_link_get_failure_with_link_present_does_not_block_startup(api, caplog): + # The create fallback hits the unique link index. A missing link must not + # stop the bot starting. + caplog.set_level(logging.WARNING) + profile = { + "displayName": "Invite roster", + "fullName": "", + "groupPreferences": { + "directMessages": {"enable": "on"}, + "commands": commands.to_wire(commands.COMMANDS), + }, + } + api.groups.append(make_group(77, profile, custom_data=MARKER)) + api.group_links[77] = "https://simplex.chat/contact#/?v=2&group=77" + api.fail_on.add("api_get_group_link_str") + api.fail_on.add("api_create_group_link") + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77 + + +async def test_pushes_commands_when_they_differ(api): + profile = { + "displayName": "Invite roster", + "fullName": "", + "groupPreferences": {"directMessages": {"enable": "on"}, "commands": []}, + } + api.groups.append(make_group(77, profile, custom_data=MARKER)) + await setup.ensure_roster_group(api, USER_ID, CONFIG) + assert len(api.profile_updates) == 1 + group_id, sent = api.profile_updates[0] + assert group_id == 77 + assert sent["groupPreferences"]["commands"] == commands.to_wire(commands.COMMANDS) + + +async def test_pushed_profile_keeps_existing_display_name(api): + profile = { + "displayName": "renamed by a human", + "fullName": "", + "groupPreferences": {"directMessages": {"enable": "on"}, "commands": []}, + } + api.groups.append(make_group(77, profile, custom_data=MARKER)) + await setup.ensure_roster_group(api, USER_ID, CONFIG) + _, sent = api.profile_updates[0] + # Syncing commands must not silently rename a group the operator renamed. + assert sent["displayName"] == "renamed by a human" + + +async def test_ignores_groups_without_the_marker(api): + api.groups.append(make_group(88, {"displayName": "Invite roster", "fullName": ""})) + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID + assert api.new_groups != [] # name match alone must not be trusted + + +async def test_ignores_groups_with_a_foreign_marker(api): + api.groups.append( + make_group( + 88, {"displayName": "x", "fullName": ""}, custom_data={"otherBot": {"group": "roster"}} + ) + ) + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID + assert api.new_groups != [] + + +async def test_ignores_groups_with_the_wrong_marker_value(api): + # Right namespace, wrong marker. + api.groups.append( + make_group( + 88, + {"displayName": "x", "fullName": ""}, + custom_data={"supportBotLight": {"group": "archive"}}, + ) + ) + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID + assert api.new_groups != [] + + +async def test_ignores_groups_with_a_non_dict_marker(api): + api.groups.append( + make_group( + 88, {"displayName": "x", "fullName": ""}, custom_data={"supportBotLight": "roster"} + ) + ) + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID + + +async def test_warns_and_picks_one_when_two_groups_are_marked(api, caplog): + profile = { + "displayName": "Invite roster", + "fullName": "", + "groupPreferences": { + "directMessages": {"enable": "on"}, + "commands": commands.to_wire(commands.COMMANDS), + }, + } + api.groups += [ + make_group(20, profile, custom_data=MARKER), + make_group(21, profile, custom_data=MARKER), + ] + api.group_links[20] = "https://simplex.chat/#g20" + with caplog.at_level("WARNING"): + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 20 + assert "2 groups carry the roster marker" in caplog.text + assert api.new_groups == [] + + +async def test_a_group_the_bot_has_left_is_not_reused(api): + # Nothing would ever be delivered there, and the marker would keep a + # replacement from being created. + api.groups.append( + make_group( + 77, + {"displayName": "old roster", "fullName": ""}, + {"supportBotLight": {"group": "roster"}}, + membership_status="removed", + ) + ) + group_id = await setup.ensure_roster_group(api, USER_ID, CONFIG) + assert group_id != 77 + assert api.new_groups # a live roster group was created instead + + +async def test_direct_messages_is_restored_when_an_owner_switches_it_off(api): + # api_create_member_contact fails without it, so /dm would fail forever with + # nothing to explain it. + profile = { + "displayName": "Invite roster", + "fullName": "", + "groupPreferences": { + "directMessages": {"enable": "off"}, + "commands": commands.to_wire(commands.COMMANDS), + }, + } + api.groups.append(make_group(77, profile, {"supportBotLight": {"group": "roster"}})) + await setup.ensure_roster_group(api, USER_ID, CONFIG) + pushed = api.profile_updates[-1][1]["groupPreferences"] + assert pushed["directMessages"] == {"enable": "on"} + assert pushed["commands"] == commands.to_wire(commands.COMMANDS) + + +async def test_a_profile_push_that_cannot_return_does_not_hang_startup(api, monkeypatch): + # The core's view queue is bounded and nothing drains it until the bot + # serves, so this write can block until it does. + monkeypatch.setattr(setup, "PROFILE_PUSH_TIMEOUT", 0.05) + + async def never_returns(group_id, profile): + await asyncio.sleep(10) + + monkeypatch.setattr(api, "api_update_group_profile", never_returns) + api.groups.append(make_group(77, {"displayName": "r", "fullName": ""}, MARKER)) + group_id = await asyncio.wait_for(setup.ensure_roster_group(api, USER_ID, CONFIG), 2) + assert group_id == 77 diff --git a/apps/simplex-support-bot-light/tests/test_text.py b/apps/simplex-support-bot-light/tests/test_text.py new file mode 100644 index 0000000000..b9a4602b58 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_text.py @@ -0,0 +1,50 @@ +from support_bot_light.text import MAX_NAME, UNNAMED, safe_name + + +def test_collapses_newlines_so_a_name_cannot_forge_a_line(): + assert safe_name("AAA\n • ceo@example.com — since 2020-01-01") == ( + "AAA • ceo@example.com — since 2020-01-01" + ) + assert "\n" not in safe_name("a\r\nb\tc") + + +def test_truncates_a_long_name(): + out = safe_name("X" * 14000) + assert len(out) == MAX_NAME + assert out.endswith("…") + + +def test_strips_non_printable_characters(): + assert safe_name("bob\x00\x07") == "bob" + + +def test_blank_and_whitespace_only_names(): + assert safe_name("") == UNNAMED + assert safe_name(" \n ") == UNNAMED + + +def test_leaves_an_ordinary_name_alone(): + assert safe_name("Narasimha") == "Narasimha" + + +def test_strips_invisible_but_printable_characters(): + # Hangul fillers and Braille blanks are Lo/So, so isprintable() lets them + # through while they render as nothing. + assert safe_name("\u3164\u3164Alice") == "Alice" + assert safe_name("\u115f\u1160Alice") == "Alice" + assert safe_name("\u2800Alice") == "Alice" + assert safe_name("\u3164" * 10) == UNNAMED + + +def test_normalises_compatibility_forms(): + assert safe_name("\uff21lice") == "Alice" + + +def test_leaves_names_in_other_scripts_alone(): + for name in ( + "\uae40\ucca0\uc218", + "Nguy\u1ec5n", + "\u0645\u062d\u0645\u062f", + "Jos\u00e9 M\u00fcller", + ): + assert safe_name(name) == name diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg new file mode 100644 index 0000000000..715a6daf99 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg new file mode 100644 index 0000000000..f70ecb91db Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg new file mode 100644 index 0000000000..48bfe6caa7 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg new file mode 100644 index 0000000000..2bab828717 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg new file mode 100644 index 0000000000..704cae8710 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg new file mode 100644 index 0000000000..d7c1f4e088 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg new file mode 100644 index 0000000000..e39e0836cb Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg new file mode 100644 index 0000000000..ce9b4a86ef Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg differ diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 476ee4f94d..d88181b778 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -61,6 +61,7 @@ This file is generated automatically. - [APISetGroupCustomData](#apisetgroupcustomdata) - [APISetContactCustomData](#apisetcontactcustomdata) - [APISetUserAutoAcceptMemberContacts](#apisetuserautoacceptmembercontacts) +- [APISetUserAutoAcceptGroupInvitations](#apisetuserautoacceptgroupinvitations) [User profile commands](#user-profile-commands) - [ShowActiveUser](#showactiveuser) @@ -1972,6 +1973,43 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APISetUserAutoAcceptGroupInvitations + +Set auto-accept group invitations. + +*Network usage*: no. + +**Parameters**: +- userId: int64 +- onOff: bool + +**Syntax**: + +``` +/_set accept group invitations on|off +``` + +```javascript +'/_set accept group invitations ' + userId + ' ' + (onOff ? 'on' : 'off') // JavaScript +``` + +```python +'/_set accept group invitations ' + str(userId) + ' ' + ('on' if onOff else 'off') # Python +``` + +**Responses**: + +CmdOk: Ok. +- type: "cmdOk" +- user_: [User](./TYPES.md#user)? + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + ## User profile commands Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents). diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 4546a6aaa7..2d26f9bb2f 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -3589,6 +3589,7 @@ ParseError: A_MESSAGE: - type: "A_MESSAGE" +- messageErr: string A_PROHIBITED: - type: "A_PROHIBITED" @@ -4366,6 +4367,7 @@ Handshake: - sendRcptsContacts: bool - sendRcptsSmallGroups: bool - autoAcceptMemberContacts: bool +- autoAcceptGroupInvitations: bool - userMemberProfileUpdatedAt: UTCTime? - userChatRelay: bool - clientService: bool diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 09ae1faa5b..d126ff1844 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -154,7 +154,8 @@ chatCommandsDocsData = ("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode"), ("APISetGroupCustomData", [], "Set group custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom #" <> Param "groupId" <> Optional "" (" " <> Json "$0") "customData"), ("APISetContactCustomData", [], "Set contact custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom @" <> Param "contactId" <> Optional "" (" " <> Json "$0") "customData"), - ("APISetUserAutoAcceptMemberContacts", [], "Set auto-accept member contacts.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept member contacts " <> Param "userId" <> " " <> OnOff "onOff") + ("APISetUserAutoAcceptMemberContacts", [], "Set auto-accept member contacts.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept member contacts " <> Param "userId" <> " " <> OnOff "onOff"), + ("APISetUserAutoAcceptGroupInvitations", [], "Set auto-accept group invitations.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept group invitations " <> Param "userId" <> " " <> OnOff "onOff") -- ("APIChatItemsRead", [], "Mark items as read.", ["CRItemsReadForChat"], [], Nothing, ""), -- ("APIChatRead", [], "Mark chat as read.", ["CRCmdOk"], [], Nothing, ""), -- ("APIChatUnread", [], "Mark chat as unread.", ["CRCmdOk"], [], Nothing, ""), @@ -297,6 +298,7 @@ cliCommands = "SetUserFeature", "SetUserGroupReceipts", "SetUserAutoAcceptMemberContacts", + "SetUserAutoAcceptGroupInvitations", "SetUserTimedMessages", "ShareMyAddress", "SharePublicGroup", diff --git a/cabal.project b/cabal.project index 3ff3ec2713..fa144d6780 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 413f30bee5a14312eca67713b5c47a3537a0c028 + tag: 0d3cf39c27aec1c51c88d7bf3d6762bc22278967 source-repository-package type: git diff --git a/docs/DIRECTORY.md b/docs/DIRECTORY.md index 50e7771a1a..9a1e26b9f7 100644 --- a/docs/DIRECTORY.md +++ b/docs/DIRECTORY.md @@ -22,32 +22,27 @@ Please note that your search queries can be kept by the bot as the conversation To add a group you must be its owner. Once you connect to the directory service and send `/help`, the service will guide you through the process. -1. Invite SimpleX Service Directory to the group as `admin` member. You can also set the role to `admin` after inviting the directory service. +1. Invite SimpleX Service Directory to the group as `admin` member. You can also set the role to `admin` after inviting the directory service. The member who invited the directory service will be the owner of the group record in the directory service. The directory service needs to be `admin` to provide a good user experience of joining the group, as it will create a new link to join the group, which is expected to be online 99% of the time. -2. Add the link sent to you by the directory service to the group welcome message. This has to be done by the same group member who invited the directory service to the group. This member will be the owner of the group record in the directory service. - -3. Once the link is added, the group will need to be approved by the directory service admins. This link is functional even before the group is approved, and you can continue using this link even if the group is not approved. +2. The group will need to be approved by the directory service admins. The directory service creates the link to join the group when the group is approved, and sends it to you. The group is usually approved within 24 hours. Please see below which groups can be added. -Once the group is approved, it will appear in search results. +Once the group is approved, it will appear in search results together with the link to join it. We recommend adding this link to the group welcome message - adding or removing it does not require a new approval. You can list all the groups you submitted by sending `/list` to the directory service. ### How to remove the group from the directory -Changing the group profile in any way (e.g., changing the group name, welcome message, or removing the link to join the group from the welcome message) will remove the group from the search results until the group is approved again by the directory service admins. +Changing the group profile (e.g., the group name, image, or the text of the welcome message) will remove the group from the search results until the group is approved again by the directory service admins. Adding or removing the directory link in the welcome message does not require a new approval. If it is undesirable that the service cannot be found in search during this time, please coordinate the time of this change with the directory service admins for quick approval. Changing the role of the directory service will temporarily remove the group from the search results, and unless you changed the role to the `owner`, it will also permanently disrupt the members that were in the process of connecting to other members via the directory service. -To remove the group from the directory: - -1. Remove the group link created by the directory service from the welcome message. This will not disrupt the members from joining the group, even via this link, but will remove the group from the search results. -2. After some time (we recommend 3-4 days) remove the directory service from the group - it will stop receiving the messages and the group will be permanently removed from the search results. +To remove the group from the directory, send `/delete :` to the directory service, with the ID and name shown by `/list`. You can also remove the directory service from the group - the group will be permanently removed from the search results. Removing the group does not prevent you from registering the group again in the future. diff --git a/docs/LINKS.md b/docs/LINKS.md index b45c9fb5c4..c1eb1e6f2b 100644 --- a/docs/LINKS.md +++ b/docs/LINKS.md @@ -1,5 +1,19 @@ # Links to Community Publications +## SimpleX Chat Wants Its 400K+ Users to Become Investors Too + +It's FOSS + +Article + +Image: itsfoss-simplex-crowdfunding.webp + +Language: English + +Date: Aug 14, 2026 + +https://itsfoss.com/news/simplex-chat-investment-drive/ + ## SimpleX Chat: Private Monero Communities — and Now a Chance to Invest Monerica @@ -16,6 +30,22 @@ Date: Aug 10, 2026 https://blog.monerica.com/articles/simplex-chat-private-monero-communities +## Web3 Summit Talk: "SimpleX Community Credits: Making Privacy Profitable" + +SimpleX Chat + +Conference talk, Video + +Alain Brenzikofer presents our design for Community Credits at Web3 Summit – a payment solution for private infrastructure payments on SimpleX network. See the whitepaper at: https://simplex.chat/credits/whitepaper.pdf + +Image: simplex-web3-summit-talk.jpg + +Language: English + +Date: Jul 2026 + +https://www.youtube.com/watch?v=UhW8AuoRgxg + ## SimpleX Chat: Product Showcase - Removing User Identifiers From Messaging Help Net Security diff --git a/docs/links/images/itsfoss-simplex-crowdfunding.webp b/docs/links/images/itsfoss-simplex-crowdfunding.webp new file mode 100644 index 0000000000..e4b52d65ec Binary files /dev/null and b/docs/links/images/itsfoss-simplex-crowdfunding.webp differ diff --git a/docs/links/images/simplex-web3-summit-talk.jpg b/docs/links/images/simplex-web3-summit-talk.jpg new file mode 100644 index 0000000000..e4edf5e221 Binary files /dev/null and b/docs/links/images/simplex-web3-summit-talk.jpg differ diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index 14b03f560f..0dfd63ba88 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -728,6 +728,21 @@ export namespace APISetUserAutoAcceptMemberContacts { } } +// Set auto-accept group invitations. +// Network usage: no. +export interface APISetUserAutoAcceptGroupInvitations { + userId: number // int64 + onOff: boolean +} + +export namespace APISetUserAutoAcceptGroupInvitations { + export type Response = CR.CmdOk | CR.ChatCmdError + + export function cmdString(self: APISetUserAutoAcceptGroupInvitations): string { + return '/_set accept group invitations ' + self.userId + ' ' + (self.onOff ? 'on' : 'off') + } +} + // User profile commands // Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents). diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 5faf084dce..1779df783e 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -3984,6 +3984,7 @@ export namespace SMPAgentError { export interface A_MESSAGE extends Interface { type: "A_MESSAGE" + messageErr: string } export interface A_PROHIBITED extends Interface { @@ -5042,6 +5043,7 @@ export interface User { sendRcptsContacts: boolean sendRcptsSmallGroups: boolean autoAcceptMemberContacts: boolean + autoAcceptGroupInvitations: boolean userMemberProfileUpdatedAt?: string // ISO-8601 timestamp userChatRelay: boolean clientService: boolean diff --git a/packages/simplex-chat-python/pyproject.toml b/packages/simplex-chat-python/pyproject.toml index 76f22cdabe..ecc6a9d6b4 100644 --- a/packages/simplex-chat-python/pyproject.toml +++ b/packages/simplex-chat-python/pyproject.toml @@ -42,6 +42,10 @@ asyncio_mode = "auto" line-length = 100 target-version = "py311" +[tool.ruff.lint] +# Generated by the Haskell codegen; regenerating is the only way to change them. +exclude = ["src/simplex_chat/types/_*.py"] + [tool.ruff.format] # `src/simplex_chat/types/*.py` are generated by the Haskell codegen # (bots/src/API/Docs/Generate/Python.hs). Re-formatting them locally diff --git a/packages/simplex-chat-python/src/simplex_chat/__init__.py b/packages/simplex-chat-python/src/simplex_chat/__init__.py index c353b74935..a60e60c820 100644 --- a/packages/simplex-chat-python/src/simplex_chat/__init__.py +++ b/packages/simplex-chat-python/src/simplex_chat/__init__.py @@ -1,5 +1,6 @@ """SimpleX Chat — Python client library for chat bots.""" +from . import util as util # re-export the util namespace from ._version import __version__ from .api import ( ChatApi, @@ -32,17 +33,16 @@ from .bot import ( VideoMessage, VoiceMessage, ) -from .core import ChatAPIError, ChatInitError, CryptoArgs, MigrationConfirmation -from . import util as util # re-export the util namespace +from .core import ChatAPIError, ChatError, ChatInitError, CryptoArgs, MigrationConfirmation __all__ = [ - "__version__", "Bot", "BotCommand", "BotProfile", "ChatAPIError", "ChatApi", "ChatCommandError", + "ChatError", "ChatInitError", "ChatMessage", "Client", @@ -68,5 +68,6 @@ __all__ = [ "UnknownMessage", "VideoMessage", "VoiceMessage", + "__version__", "util", ] diff --git a/packages/simplex-chat-python/src/simplex_chat/__main__.py b/packages/simplex-chat-python/src/simplex_chat/__main__.py index 2fa4f3cd37..d14fa377b0 100644 --- a/packages/simplex-chat-python/src/simplex_chat/__main__.py +++ b/packages/simplex-chat-python/src/simplex_chat/__main__.py @@ -26,7 +26,7 @@ def main(argv: list[str] | None = None) -> int: path = _native._resolve_libs_dir(args.backend) print(f"libsimplex installed at: {path}") return 0 - except Exception as e: + except Exception as e: # noqa: BLE001 - a CLI: report any failure, don't traceback print(f"install failed: {e}", file=sys.stderr) return 1 diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py index 313c606883..4c408479bb 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_native.py +++ b/packages/simplex-chat-python/src/simplex_chat/_native.py @@ -99,7 +99,7 @@ def _stream_to_file(url: str, dest: Path, *, timeout: float = 60.0) -> None: `timeout` is per-request; we don't touch `socket.setdefaulttimeout` so other socket users in the same process aren't affected. """ - with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 - https://github.com/... + with urllib.request.urlopen(url, timeout=timeout) as resp: total = int(resp.headers.get("Content-Length") or 0) received = 0 with dest.open("wb") as out: @@ -112,7 +112,7 @@ def _stream_to_file(url: str, dest: Path, *, timeout: float = 60.0) -> None: else: msg = f"\r download: {received >> 20} MiB" print(msg, end="", file=sys.stderr, flush=True) - print("", file=sys.stderr, flush=True) # newline after final progress line + print(file=sys.stderr, flush=True) # newline after final progress line def _download(target: Path, backend: Backend) -> None: diff --git a/packages/simplex-chat-python/src/simplex_chat/api.py b/packages/simplex-chat-python/src/simplex_chat/api.py index 51de063329..e3d36c45df 100644 --- a/packages/simplex-chat-python/src/simplex_chat/api.py +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -8,7 +8,7 @@ from typing import Any, Literal from . import _native, core, util from .core import MigrationConfirmation -from .types import CC, CEvt, CR, T +from .types import CC, CR, CEvt, T # Mirrors Node `ConnReqType` enum (api.ts:15-18) — the two possible outcomes # of `api_connect` / `api_connect_active_user` depending on the link kind. @@ -39,7 +39,7 @@ def _db_to_migrate_args(db: Db) -> tuple[str, str, _native.Backend]: raise TypeError(f"Unknown db: {db!r}") -class ChatCommandError(Exception): +class ChatCommandError(core.ChatError): """A chat command returned an unexpected response type. `response` is the raw wire response; `response_type` exposes its `type` @@ -71,7 +71,7 @@ class ChatApi: cls, db: Db, confirm: MigrationConfirmation = MigrationConfirmation.YES_UP, - ) -> "ChatApi": + ) -> ChatApi: path_or_prefix, key_or_conn, backend = _db_to_migrate_args(db) # Trigger lazy lib load with the right backend BEFORE chat_migrate_init. _native.lib_for(backend) @@ -96,8 +96,12 @@ class ChatApi: return self._started async def start_chat(self) -> None: + # serviceRequests is off: a bot answers its own address, it does not + # serve requests routed to it as a service. r = await self.send_chat_cmd( - CC.StartChat_cmd_string({"mainApp": True, "enableSndFiles": True}) + CC.StartChat_cmd_string( + {"mainApp": True, "enableSndFiles": True, "serviceRequests": False} + ) ) if r.get("type") not in ("chatStarted", "chatRunning"): raise ChatCommandError("error starting chat", r) @@ -142,12 +146,7 @@ class ChatApi: return r["contactLink"] raise ChatCommandError("error loading user address", r) except core.ChatAPIError as e: - ce = e.chat_error - if ( - ce is not None - and ce.get("type") == "errorStore" - and ce.get("storeError", {}).get("type") == "userContactLinkNotFound" - ): + if e.store_error_type == "userContactLinkNotFound": return None raise @@ -510,8 +509,10 @@ class ChatApi: raise ChatCommandError("error accepting contact request", r) async def api_reject_contact_request(self, contact_req_id: int) -> None: + # notify is not rendered into the command string, so the core reads its + # own default of off; this only keeps the argument type satisfied. r = await self.send_chat_cmd( - CC.APIRejectContact_cmd_string({"contactReqId": contact_req_id}) + CC.APIRejectContact_cmd_string({"contactReqId": contact_req_id, "notify": False}) ) if r["type"] != "contactRequestRejected": raise ChatCommandError("error rejecting contact request", r) @@ -607,6 +608,28 @@ class ChatApi: if r["type"] != "cmdOk": raise ChatCommandError("error setting contact custom data", r) + async def api_merge_contact_custom_data( + self, contact: T.Contact, key: str, value: object | None + ) -> None: + """Set or drop one key of a contact's custom data, keeping the rest. + + The set command replaces the whole column. `value=None` removes `key`. + """ + await self.api_set_contact_custom_data( + contact["contactId"], util.merged_custom_data(contact.get("customData"), key, value) + ) + + async def api_merge_group_custom_data( + self, group: T.GroupInfo, key: str, value: object | None + ) -> None: + """Set or drop one key of a group's custom data, keeping the rest. + + See `api_merge_contact_custom_data`. + """ + await self.api_set_group_custom_data( + group["groupId"], util.merged_custom_data(group.get("customData"), key, value) + ) + async def api_set_auto_accept_member_contacts(self, user_id: int, on_off: bool) -> None: r = await self.send_chat_cmd( CC.APISetUserAutoAcceptMemberContacts_cmd_string({"userId": user_id, "onOff": on_off}) @@ -632,12 +655,7 @@ class ChatApi: return r["user"] raise ChatCommandError("unexpected response", r) except core.ChatAPIError as e: - ce = e.chat_error - if ( - ce is not None - and ce.get("type") == "error" - and ce.get("errorType", {}).get("type") == "noActiveUser" - ): + if e.error_type == "noActiveUser": return None raise @@ -719,3 +737,13 @@ class ChatApi: if r["type"] == "newMemberContactSentInv": return r["contact"] raise ChatCommandError("error sending member contact invitation", r) + + async def api_accept_member_contact(self, contact_id: int) -> T.Contact: + """Accept a direct connection a group member opened with us. + + The core rejects a second accept with "connection already started". + """ + r = await self.send_chat_cmd(f"/_accept member contact @{contact_id}") + if r["type"] == "memberContactAccepted": + return r["contact"] + raise ChatCommandError("error accepting member contact", r) diff --git a/packages/simplex-chat-python/src/simplex_chat/bot.py b/packages/simplex-chat-python/src/simplex_chat/bot.py index 4e385493b2..b3e5b5ec03 100644 --- a/packages/simplex-chat-python/src/simplex_chat/bot.py +++ b/packages/simplex-chat-python/src/simplex_chat/bot.py @@ -121,8 +121,8 @@ class Bot(Client): async def _post_start(self, user: T.User) -> None: """Bots sync address first, then embed the link in the profile.""" - link = await self._sync_address(user) - await self._maybe_sync_profile(user, contact_link=link) + self._contact_link = await self._sync_address(user) + await self._maybe_sync_profile(user) async def _sync_address(self, user: T.User) -> str | None: """Address sync. Returns the public link if any, for embedding in the profile.""" diff --git a/packages/simplex-chat-python/src/simplex_chat/client.py b/packages/simplex-chat-python/src/simplex_chat/client.py index b0d144b8b9..8ec955b54a 100644 --- a/packages/simplex-chat-python/src/simplex_chat/client.py +++ b/packages/simplex-chat-python/src/simplex_chat/client.py @@ -14,7 +14,7 @@ import os import signal as _signal from collections.abc import AsyncIterator, Awaitable, Callable from dataclasses import dataclass -from typing import Any, Generic, Literal, TypeVar, overload +from typing import Any, Generic, Literal, Self, TypeVar, overload from . import util from .api import ChatApi, ChatCommandError, ContactAlreadyExistsError, Db @@ -58,7 +58,7 @@ class ParsedCommand: class Message(Generic[C]): chat_item: T.AChatItem content: C - client: "Client" + client: Client @property def chat_info(self) -> T.ChatInfo: @@ -71,7 +71,7 @@ class Message(Generic[C]): return c.get("text") # type: ignore[return-value] return None - async def reply(self, text: str) -> "Message[T.MsgContent]": + async def reply(self, text: str) -> Message[T.MsgContent]: items = await self.client.api.api_send_text_reply(self.chat_item, text) ci = items[0] content = ci["chatItem"]["content"] @@ -79,7 +79,7 @@ class Message(Generic[C]): msg_content: T.MsgContent = content["msgContent"] # type: ignore[index] return Message(chat_item=ci, content=msg_content, client=self.client) - async def reply_content(self, content: T.MsgContent) -> "Message[T.MsgContent]": + async def reply_content(self, content: T.MsgContent) -> Message[T.MsgContent]: items = await self.client.api.api_send_messages( self.chat_info, [{"msgContent": content, "mentions": {}}] ) @@ -162,6 +162,11 @@ class Client: self._api: ChatApi | None = None self._serving = False self._stop_event = asyncio.Event() + # Set by Bot once its address is known, so a later `sync_profile()` + # embeds the same link the startup sync would have. + self._contact_link: str | None = None + self._signal_handlers_installed = False + self._interrupts = 0 self._message_handlers: list[tuple[Callable[[Message[Any]], bool], MessageHandler]] = [] self._command_handlers: list[ tuple[tuple[str, ...], Callable[[Message[Any]], bool], CommandHandler] @@ -184,6 +189,26 @@ class Client: raise RuntimeError("Client not initialized — call run() or use `async with client:`") return self._api + @property + def profile(self) -> Profile: + """The profile this client identifies with. + + Mutable: change a field and call `sync_profile()` to apply it. + """ + return self._profile + + @profile.setter + def profile(self, profile: Profile) -> None: + self._profile = profile + + @property + def stop_requested(self) -> bool: + """Whether `stop()` has been called, including during startup. + + Sticky: a caller doing its own setup can unwind instead of serving. + """ + return self._stop_event.is_set() + # ------------------------------------------------------------------ # # Decorators # ------------------------------------------------------------------ # @@ -312,16 +337,12 @@ class Client: # Lifecycle # ------------------------------------------------------------------ # - async def __aenter__(self) -> "Client": + async def __aenter__(self) -> Self: # Order matters: libsimplex `/_start` requires an active user, so # ensure (or create) the user first, THEN start the chat, THEN # do post-start setup (profile sync; Bot adds address sync). - # Clear `_stop_event` here (not in `serve_forever`/`events`) so that - # a `stop()` call landing between `__aenter__` and the receive loop - # — e.g. a signal handler firing while signal handlers are being - # wired up — is preserved and causes the loop to exit immediately - # on entry. - self._stop_event.clear() + # `_stop_event` is never cleared: a stop requested during startup has + # to survive into the receive loop. A stopped client is spent. self._api = await ChatApi.init(self._db, self._confirm_migrations) try: user = await self._ensure_active_user() @@ -372,7 +393,7 @@ class Client: Default (Client): sync profile only. Bot overrides to also sync its address and embed the connection link in the profile. """ - await self._maybe_sync_profile(user, contact_link=None) + await self._maybe_sync_profile(user) def run(self) -> None: """Blocking entry: runs serve_forever() with SIGINT/SIGTERM handlers installed. @@ -390,33 +411,39 @@ class Client: ) async def _main() -> None: + # Before startup: a signal during migrations would otherwise hit + # the default disposition and kill the process mid-write. + self.install_signal_handlers() async with self: - loop = asyncio.get_running_loop() - # First Ctrl+C → graceful stop (~500ms, bounded by the - # receive-loop poll interval). Second Ctrl+C → force-exit - # immediately (in case stop_chat / close hang on a wedged - # FFI call). Standard CLI UX (jupyter, ipython, …). - sigint_count = 0 - - def on_interrupt() -> None: - nonlocal sigint_count - sigint_count += 1 - if sigint_count == 1: - log.info("stopping... (press Ctrl+C again to force exit)") - self.stop() - else: - os._exit(130) # 128 + SIGINT - - if hasattr(_signal, "SIGINT"): - try: - loop.add_signal_handler(_signal.SIGINT, on_interrupt) - loop.add_signal_handler(_signal.SIGTERM, self.stop) - except NotImplementedError: # Windows - _signal.signal(_signal.SIGINT, lambda *_: on_interrupt()) await self.serve_forever() asyncio.run(_main()) + def install_signal_handlers(self) -> None: + """Route SIGINT and SIGTERM to `stop()`. Idempotent. + + `run()` calls this itself; call it directly when driving the client + yourself. First Ctrl+C stops, a second force-exits. Needs a running loop. + """ + if self._signal_handlers_installed or not hasattr(_signal, "SIGINT"): + return + self._signal_handlers_installed = True + + def on_interrupt() -> None: + self._interrupts += 1 + if self._interrupts == 1: + log.info("stopping... (press Ctrl+C again to force exit)") + self.stop() + else: + os._exit(130) # 128 + SIGINT + + try: + loop = asyncio.get_running_loop() + loop.add_signal_handler(_signal.SIGINT, on_interrupt) + loop.add_signal_handler(_signal.SIGTERM, self.stop) + except NotImplementedError: # Windows + _signal.signal(_signal.SIGINT, lambda *_: on_interrupt()) + async def serve_forever(self) -> None: if self._serving: raise RuntimeError("already serving") @@ -450,10 +477,7 @@ class Client: self._serving = True try: while not self._stop_event.is_set(): - try: - event = await self.api.recv_chat_event(wait_us=500_000) - except asyncio.CancelledError: - raise + event = await self.api.recv_chat_event(wait_us=500_000) if event is None: continue try: @@ -551,7 +575,7 @@ class Client: text: str, *, timeout: float = 30.0, - ) -> "Message[T.MsgContent]": + ) -> Message[T.MsgContent]: """Send text to a direct contact and wait for the next reply from them. Waiters are FIFO per contact_id: two concurrent calls to the same @@ -815,20 +839,35 @@ class Client: log.info("user: %s", user["profile"]["displayName"]) return user - async def _maybe_sync_profile(self, user: T.User, *, contact_link: str | None) -> None: + async def sync_profile(self) -> bool: + """Apply the current `profile` to the active user. True if it changed. + + For what the startup sync cannot know yet, such as a display name that + depends on the database. Raises `ChatAPIError` if the core refuses it. + """ + user = await self.api.api_get_active_user() + if user is None: + raise RuntimeError("no active user") + return await self._sync_profile(user) + + async def _maybe_sync_profile(self, user: T.User) -> bool: + """The startup sync — `sync_profile()` unless the caller opted out.""" + if not self._update_profile: + return False + return await self._sync_profile(user) + + async def _sync_profile(self, user: T.User) -> bool: """Update the user profile on the wire if its fields changed. - `contact_link` is only set by Bot (to embed its address). Mirrors + `_contact_link` is only set by Bot (to embed its address). Mirrors Node `updateBotUserProfile` (bot.ts:199-214). Field-by-field comparison because user["profile"] is LocalProfile (has extra fields profileId, localAlias, preferences, peerType) so a full dict equality would always differ. """ - if not self._update_profile: - return new_profile = self._profile_to_wire() - if contact_link is not None: - new_profile["contactLink"] = contact_link + if self._contact_link is not None: + new_profile["contactLink"] = self._contact_link cur = user["profile"] changed = ( cur["displayName"] != new_profile["displayName"] @@ -842,6 +881,7 @@ class Client: if changed: log.info("profile changed, updating...") await self.api.api_update_profile(user["userId"], new_profile) + return changed def _profile_to_wire(self) -> T.Profile: """Convert the user-facing Profile dataclass to wire format. @@ -857,7 +897,7 @@ class Client: if self._profile.short_descr is not None: p["shortDescr"] = self._profile.short_descr if self._profile.image is not None: - p["image"] = self._profile.image + p["image"] = util.check_profile_image(self._profile.image) return p # ------------------------------------------------------------------ # diff --git a/packages/simplex-chat-python/src/simplex_chat/core.py b/packages/simplex-chat-python/src/simplex_chat/core.py index 075db34b52..4fc847f7de 100644 --- a/packages/simplex-chat-python/src/simplex_chat/core.py +++ b/packages/simplex-chat-python/src/simplex_chat/core.py @@ -13,16 +13,46 @@ from enum import StrEnum from typing import Any, TypedDict from . import _native -from .types import T, CR, CEvt +from .types import CR, CEvt, T -class ChatAPIError(Exception): +class ChatError(Exception): + """Base class for every failure of a chat command. + + Catch this for both `ChatAPIError` and `api.ChatCommandError`. + """ + + +class ChatAPIError(ChatError): """Raised when chat_send_cmd / chat_recv_msg_wait returns a chat error.""" def __init__(self, message: str, chat_error: T.ChatError | None = None): super().__init__(message) self.chat_error = chat_error + @property + def error_type(self) -> str | None: + """Tag of the nested `errorType`, e.g. `noActiveUser`, or None.""" + return self._nested("errorType").get("type") + + @property + def store_error_type(self) -> str | None: + """Tag of the nested `storeError`, e.g. `duplicateName`, or None.""" + return self._nested("storeError").get("type") + + @property + def command_error(self) -> str | None: + """What the core says the caller did wrong, or None. + + The only part of a `commandError` worth reading: the tag says nothing. + """ + error = self._nested("errorType") + return error.get("message") if error.get("type") == "commandError" else None + + def _nested(self, key: str) -> dict[str, Any]: + nested = (self.chat_error or {}).get(key) # type: ignore[attr-defined] + return nested if isinstance(nested, dict) else {} + class ChatInitError(Exception): """Raised when chat_migrate_init returns a DBMigrationResult error.""" diff --git a/packages/simplex-chat-python/src/simplex_chat/filters.py b/packages/simplex-chat-python/src/simplex_chat/filters.py index 8af15c1c66..a119ede25a 100644 --- a/packages/simplex-chat-python/src/simplex_chat/filters.py +++ b/packages/simplex-chat-python/src/simplex_chat/filters.py @@ -3,7 +3,8 @@ from __future__ import annotations import re -from typing import Any, Callable +from collections.abc import Callable +from typing import Any def compile_message_filter(kw: dict[str, Any]) -> Callable[[Any], bool]: diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index f73a4fa4f7..086b3cfd29 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -637,6 +637,19 @@ def APISetUserAutoAcceptMemberContacts_cmd_string(self: APISetUserAutoAcceptMemb APISetUserAutoAcceptMemberContacts_Response = CR.CmdOk | CR.ChatCmdError +# Set auto-accept group invitations. +# Network usage: no. +class APISetUserAutoAcceptGroupInvitations(TypedDict): + userId: int # int64 + onOff: bool + + +def APISetUserAutoAcceptGroupInvitations_cmd_string(self: APISetUserAutoAcceptGroupInvitations) -> str: + return '/_set accept group invitations ' + str(self['userId']) + ' ' + ('on' if self['onOff'] else 'off') + +APISetUserAutoAcceptGroupInvitations_Response = CR.CmdOk | CR.ChatCmdError + + # User profile commands # Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents). diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index b00a559f92..4c30c83bf5 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -2764,6 +2764,7 @@ class RoleGroupPreference(TypedDict): class SMPAgentError_A_MESSAGE(TypedDict): type: Literal["A_MESSAGE"] + messageErr: str class SMPAgentError_A_PROHIBITED(TypedDict): type: Literal["A_PROHIBITED"] @@ -3526,6 +3527,7 @@ class User(TypedDict): sendRcptsContacts: bool sendRcptsSmallGroups: bool autoAcceptMemberContacts: bool + autoAcceptGroupInvitations: bool userMemberProfileUpdatedAt: NotRequired[str] # ISO-8601 timestamp userChatRelay: bool clientService: bool diff --git a/packages/simplex-chat-python/src/simplex_chat/util.py b/packages/simplex-chat-python/src/simplex_chat/util.py index 158bb72a79..e5fbbf3fab 100644 --- a/packages/simplex-chat-python/src/simplex_chat/util.py +++ b/packages/simplex-chat-python/src/simplex_chat/util.py @@ -120,6 +120,46 @@ def ci_bot_command(chat_item: T.ChatItem) -> tuple[str, str] | None: return m.group(1), m.group(2).strip() +def merged_custom_data( + custom_data: dict[str, object] | None, key: str, value: object | None +) -> dict[str, object] | None: + """`custom_data` with `key` set to `value`, or removed when `value` is None. + + Returns None, which the set commands read as "clear the column", if empty. + """ + data = dict(custom_data or {}) + if value is None: + data.pop(key, None) + else: + data[key] = value + return data or None + + +# The apps decode these two and nothing else (base64ToBitmap in mobile and +# desktop), while the core stores any string starting with "data:". +PROFILE_IMAGE_PREFIXES = ("data:image/png;base64,", "data:image/jpg;base64,") + + +def check_profile_image(image: str) -> str: + """`image` unchanged, or ValueError if no client could render it. + + An image the apps cannot decode is still stored and broadcast, and shows + as an empty avatar to everyone. + """ + if image.startswith(PROFILE_IMAGE_PREFIXES): + return image + raise ValueError(f"profile image must start with {' or '.join(PROFILE_IMAGE_PREFIXES)}") + + +def conn_status(contact: T.Contact) -> str | None: + """Tag of a contact's active connection status, or None if it has none. + + A contact exists before its connection does, so the two are not the same. + """ + status = (contact.get("activeConn") or {}).get("connStatus") or {} + return status.get("type") + + def reaction_text(reaction: T.ACIReaction) -> str: """Format an `ACIReaction` as the emoji character or tag string.""" r = reaction["chatReaction"]["reaction"] # type: ignore[index] diff --git a/packages/simplex-chat-python/tests/test_api.py b/packages/simplex-chat-python/tests/test_api.py new file mode 100644 index 0000000000..09b5ce4c03 --- /dev/null +++ b/packages/simplex-chat-python/tests/test_api.py @@ -0,0 +1,174 @@ +"""ChatApi commands and error classification, without the native controller. + +`ChatApi` only touches the FFI through `send_chat_cmd`, so replacing that one +method exercises every wrapper: the command string it builds and the response +shape it accepts. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from simplex_chat import ChatApi, ChatAPIError, ChatCommandError, ChatError + + +class FakeCtrl(ChatApi): + """ChatApi with the FFI call replaced by a scripted response.""" + + def __init__(self, response: Any = None, raises: Exception | None = None) -> None: + super().__init__(ctrl=1) + self.response = response + self.raises = raises + self.sent: list[str] = [] + + async def send_chat_cmd(self, cmd: str) -> Any: + self.sent.append(cmd) + if self.raises is not None: + raise self.raises + return self.response + + +# ---------------------------------------------------------------------- # +# Error hierarchy +# ---------------------------------------------------------------------- # + + +def test_both_command_failures_share_one_base(): + # The two are raised from different layers for the same kind of failure; + # callers should not have to name both. + assert issubclass(ChatAPIError, ChatError) + assert issubclass(ChatCommandError, ChatError) + + +def test_store_error_type_reads_the_nested_tag(): + e = ChatAPIError("x", {"type": "errorStore", "storeError": {"type": "duplicateName"}}) + assert e.store_error_type == "duplicateName" + assert e.error_type is None + + +def test_error_type_reads_the_nested_tag(): + e = ChatAPIError("x", {"type": "error", "errorType": {"type": "noActiveUser"}}) + assert e.error_type == "noActiveUser" + assert e.store_error_type is None + + +def test_command_error_carries_the_message(): + # The tag is always "commandError"; the message is the whole content. + e = ChatAPIError( + "x", {"type": "error", "errorType": {"type": "commandError", "message": "name too long"}} + ) + assert e.command_error == "name too long" + + +def test_command_error_of_another_failure(): + e = ChatAPIError("x", {"type": "errorStore", "storeError": {"type": "duplicateName"}}) + assert e.command_error is None + + +def test_error_tags_of_an_unrelated_error(): + e = ChatAPIError("x", {"type": "errorAgent", "agentError": {"type": "CRITICAL"}}) + assert e.error_type is None + assert e.store_error_type is None + + +def test_error_tags_without_a_chat_error(): + # Raised when the controller returns something that is not valid JSON-RPC. + e = ChatAPIError("invalid chat command result") + assert e.error_type is None + assert e.store_error_type is None + + +# ---------------------------------------------------------------------- # +# Errors surfaced as absence +# ---------------------------------------------------------------------- # + + +async def test_missing_address_reads_as_none(): + api = FakeCtrl( + raises=ChatAPIError( + "x", {"type": "errorStore", "storeError": {"type": "userContactLinkNotFound"}} + ) + ) + assert await api.api_get_user_address(1) is None + + +async def test_another_store_error_still_raises(): + api = FakeCtrl( + raises=ChatAPIError("x", {"type": "errorStore", "storeError": {"type": "dBBusyError"}}) + ) + with pytest.raises(ChatAPIError): + await api.api_get_user_address(1) + + +async def test_no_active_user_reads_as_none(): + api = FakeCtrl( + raises=ChatAPIError("x", {"type": "error", "errorType": {"type": "noActiveUser"}}) + ) + assert await api.api_get_active_user() is None + + +async def test_another_error_from_the_user_query_still_raises(): + api = FakeCtrl( + raises=ChatAPIError("x", {"type": "error", "errorType": {"type": "invalidConnReq"}}) + ) + with pytest.raises(ChatAPIError): + await api.api_get_active_user() + + +# ---------------------------------------------------------------------- # +# Member contacts +# ---------------------------------------------------------------------- # + + +async def test_accept_member_contact(): + contact = {"contactId": 7} + api = FakeCtrl({"type": "memberContactAccepted", "contact": contact}) + assert await api.api_accept_member_contact(7) is contact + assert api.sent == ["/_accept member contact @7"] + + +async def test_accept_member_contact_rejected(): + # The core answers a second accept with a command error, not a contact. + api = FakeCtrl({"type": "chatCmdError"}) + with pytest.raises(ChatCommandError): + await api.api_accept_member_contact(7) + + +# ---------------------------------------------------------------------- # +# Custom data +# ---------------------------------------------------------------------- # + + +async def test_merge_contact_custom_data_keeps_other_keys(): + api = FakeCtrl({"type": "cmdOk"}) + contact = {"contactId": 4, "customData": {"other": 1}} + await api.api_merge_contact_custom_data(contact, "mine", {"roster": "active"}) + assert api.sent == ['/_set custom @4 {"other": 1, "mine": {"roster": "active"}}'] + + +async def test_merge_contact_custom_data_removing_the_last_key_clears_the_column(): + api = FakeCtrl({"type": "cmdOk"}) + contact = {"contactId": 4, "customData": {"mine": 1}} + await api.api_merge_contact_custom_data(contact, "mine", None) + assert api.sent == ["/_set custom @4"] + + +async def test_merge_group_custom_data_keeps_other_keys(): + api = FakeCtrl({"type": "cmdOk"}) + group = {"groupId": 9, "customData": {"other": 1}} + await api.api_merge_group_custom_data(group, "mine", {"rostered": True}) + assert api.sent == ['/_set custom #9 {"other": 1, "mine": {"rostered": true}}'] + + +async def test_merge_group_custom_data_on_a_group_with_no_custom_data(): + api = FakeCtrl({"type": "cmdOk"}) + await api.api_merge_group_custom_data({"groupId": 9}, "mine", 1) + assert api.sent == ['/_set custom #9 {"mine": 1}'] + + +async def test_a_failed_custom_data_write_raises(): + api = FakeCtrl({"type": "chatCmdError"}) + with pytest.raises(ChatCommandError): + await api.api_merge_group_custom_data({"groupId": 9}, "mine", 1) diff --git a/packages/simplex-chat-python/tests/test_client_and_waiters.py b/packages/simplex-chat-python/tests/test_client_and_waiters.py index 7c01ae576a..d40c74a0eb 100644 --- a/packages/simplex-chat-python/tests/test_client_and_waiters.py +++ b/packages/simplex-chat-python/tests/test_client_and_waiters.py @@ -614,3 +614,238 @@ def test_events_raises_if_already_serving(): pass asyncio.run(go()) + + +class _StubApi: + """The controller calls `__aenter__` makes, with nothing behind them.""" + + def __init__(self) -> None: + self.profiles: list[dict] = [] + self.user: dict = {"userId": 1, "profile": {"displayName": "x", "fullName": ""}} + self.address: dict | None = None + + @classmethod + async def init(cls, *_a, **_kw): + return cls() + + @property + def started(self): + return False + + async def start_chat(self): + pass + + async def stop_chat(self): + pass + + async def close(self): + pass + + async def api_get_active_user(self): + return self.user + + async def api_update_profile(self, _user_id, profile): + self.profiles.append(profile) + + async def api_get_user_address(self, _user_id): + return self.address + + async def api_set_address_settings(self, _user_id, _settings): + pass + + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + +def _client_with_stub_api(monkeypatch, **kw) -> tuple[Client, _StubApi]: + import simplex_chat.client as client_mod + + api = _StubApi() + monkeypatch.setattr(client_mod, "ChatApi", _init_returning(api)) + client = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"), **kw) + return client, api + + +def _init_returning(api: _StubApi): + """A stand-in for the ChatApi class whose `init` hands back `api`.""" + return type("_Init", (), {"init": staticmethod(lambda *_a, **_kw: _done(api))}) + + +async def _done(value): + return value + + +def test_stop_before_start_is_not_lost(monkeypatch): + """A signal handler installed before startup — the only way to survive a + Ctrl+C during database migrations — sets the stop event before __aenter__ + runs. Clearing it there would begin serving a client the operator has + already stopped.""" + c, api = _client_with_stub_api(monkeypatch) + + async def go(): + c.stop() + assert c.stop_requested + async with c: + assert c.stop_requested, "stop intent was cleared by __aenter__" + await c.serve_forever() # must return immediately, never polling + + api.recv_chat_event = _never_called # type: ignore[attr-defined] + asyncio.run(go()) + + +async def _never_called(*_a, **_kw): + raise AssertionError("receive loop should have exited immediately") + + +def test_stop_requested_is_false_until_stopped(monkeypatch): + c, _ = _client_with_stub_api(monkeypatch) + assert not c.stop_requested + c.stop() + assert c.stop_requested + + +def test_install_signal_handlers_routes_both_signals(monkeypatch): + import signal as signal_mod + + c, _ = _client_with_stub_api(monkeypatch) + registered: dict[int, object] = {} + + async def go(): + loop = asyncio.get_running_loop() + monkeypatch.setattr( + loop, "add_signal_handler", lambda sig, cb, *a: registered.__setitem__(sig, cb) + ) + c.install_signal_handlers() + + asyncio.run(go()) + assert set(registered) == {signal_mod.SIGINT, signal_mod.SIGTERM} + registered[signal_mod.SIGINT]() # type: ignore[operator] + assert c.stop_requested + + +def test_install_signal_handlers_is_idempotent(monkeypatch): + c, _ = _client_with_stub_api(monkeypatch) + calls: list[int] = [] + + async def go(): + loop = asyncio.get_running_loop() + monkeypatch.setattr(loop, "add_signal_handler", lambda sig, cb, *a: calls.append(sig)) + c.install_signal_handlers() + c.install_signal_handlers() + + asyncio.run(go()) + assert len(calls) == 2, "second call re-registered the handlers" + + +def test_second_interrupt_force_exits(monkeypatch): + """A stop that hangs in stop_chat/close must not trap the operator.""" + import signal as signal_mod + + import simplex_chat.client as client_mod + + c, _ = _client_with_stub_api(monkeypatch) + registered: dict[int, object] = {} + exits: list[int] = [] + monkeypatch.setattr(client_mod.os, "_exit", lambda code: exits.append(code)) + + async def go(): + loop = asyncio.get_running_loop() + monkeypatch.setattr( + loop, "add_signal_handler", lambda sig, cb, *a: registered.__setitem__(sig, cb) + ) + c.install_signal_handlers() + + asyncio.run(go()) + on_interrupt = registered[signal_mod.SIGINT] + on_interrupt() # type: ignore[operator] + assert exits == [] + on_interrupt() # type: ignore[operator] + assert exits == [130] + + +def test_sync_profile_applies_a_change_made_after_start(monkeypatch): + """The name a bot can use may only be knowable once the database is + readable, which is after start. Without this the profile could only be + set before the client was started.""" + c, api = _client_with_stub_api(monkeypatch, update_profile=False) + + async def go(): + async with c: + assert api.profiles == [], "update_profile=False still synced on start" + c.profile.display_name = "Helpdesk" + assert await c.sync_profile() is True + + asyncio.run(go()) + assert api.profiles == [{"displayName": "Helpdesk", "fullName": ""}] + + +def test_sync_profile_is_a_no_op_when_nothing_differs(monkeypatch): + """api_update_profile broadcasts to every contact; an unchanged profile + must not become traffic for all of them.""" + c, api = _client_with_stub_api(monkeypatch, update_profile=False) + + async def go(): + async with c: + assert await c.sync_profile() is False + + asyncio.run(go()) + assert api.profiles == [] + + +def test_sync_profile_without_an_active_user(monkeypatch): + c, api = _client_with_stub_api(monkeypatch, update_profile=False) + + async def go(): + async with c: + api.user = None # type: ignore[assignment] + with pytest.raises(RuntimeError, match="no active user"): + await c.sync_profile() + + asyncio.run(go()) + + +def test_sync_profile_keeps_the_bot_address_in_the_profile(monkeypatch): + """The address is embedded by the startup sync; a later sync must not + drop it, or the profile would stop advertising where to connect.""" + import simplex_chat.client as client_mod + + api = _StubApi() + api.address = { + "connLinkContact": {"connFullLink": "https://l"}, + "addressSettings": {"businessAddress": False, "autoAccept": {"acceptIncognito": False}}, + } + api.user = { + "userId": 1, + "profile": {"displayName": "x", "fullName": "", "contactLink": "https://l"}, + } + monkeypatch.setattr(client_mod, "ChatApi", _init_returning(api)) + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + update_profile=False, + ) + + async def go(): + async with bot: + bot.profile.display_name = "Helpdesk" + await bot.sync_profile() + + asyncio.run(go()) + assert api.profiles[0]["contactLink"] == "https://l" + + +def test_profile_can_be_replaced(monkeypatch): + c, _ = _client_with_stub_api(monkeypatch) + c.profile = Profile(display_name="other", full_name="Other") + assert c._profile_to_wire() == {"displayName": "other", "fullName": "Other"} + + +def test_the_profile_image_is_checked_before_it_is_sent(monkeypatch): + """An image the apps cannot decode is stored and broadcast by the core, + and then shows as an empty avatar to every contact.""" + c, _ = _client_with_stub_api(monkeypatch) + c.profile = Profile(display_name="x", image="data:image/jpeg;base64,AAA") + with pytest.raises(ValueError, match="must start with"): + c._profile_to_wire() + c.profile = Profile(display_name="x", image="data:image/png;base64,AAA") + assert c._profile_to_wire()["image"] == "data:image/png;base64,AAA" diff --git a/packages/simplex-chat-python/tests/test_codegen.py b/packages/simplex-chat-python/tests/test_codegen.py index 509d919cfd..c5842f5d56 100644 --- a/packages/simplex-chat-python/tests/test_codegen.py +++ b/packages/simplex-chat-python/tests/test_codegen.py @@ -2,7 +2,7 @@ import typing -from simplex_chat.types import CC, CEvt, CR, T +from simplex_chat.types import CC, CR, CEvt, T def test_types_module_imports(): diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py index 55084eeae8..c2938ee3e4 100644 --- a/packages/simplex-chat-python/tests/test_native_cache.py +++ b/packages/simplex-chat-python/tests/test_native_cache.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from simplex_chat._native import _cache_root, _resolve_libs_dir, _download +from simplex_chat._native import _cache_root, _download, _resolve_libs_dir from simplex_chat._version import LIBS_VERSION diff --git a/packages/simplex-chat-python/tests/test_native_url.py b/packages/simplex-chat-python/tests/test_native_url.py index df96fff8ae..12270c9db1 100644 --- a/packages/simplex-chat-python/tests/test_native_url.py +++ b/packages/simplex-chat-python/tests/test_native_url.py @@ -1,6 +1,8 @@ from unittest.mock import patch + import pytest -from simplex_chat._native import _platform_tag, _libs_url, _libname + +from simplex_chat._native import _libname, _libs_url, _platform_tag from simplex_chat._version import LIBS_VERSION diff --git a/packages/simplex-chat-python/tests/test_util.py b/packages/simplex-chat-python/tests/test_util.py index 983b1c2a56..3ea0d87e6d 100644 --- a/packages/simplex-chat-python/tests/test_util.py +++ b/packages/simplex-chat-python/tests/test_util.py @@ -1,3 +1,5 @@ +import pytest + from simplex_chat import util @@ -173,3 +175,73 @@ def test_reaction_text_emoji(): def test_reaction_text_tag(): r = {"chatReaction": {"reaction": {"type": "unknown", "tag": "thumbs_up"}}} assert util.reaction_text(r) == "thumbs_up" + + +def test_merged_custom_data_adds_a_key_keeping_the_others(): + data = {"other": {"kept": True}} + assert util.merged_custom_data(data, "mine", {"roster": "active"}) == { + "other": {"kept": True}, + "mine": {"roster": "active"}, + } + + +def test_merged_custom_data_does_not_mutate_the_original(): + data = {"other": 1} + util.merged_custom_data(data, "mine", 2) + assert data == {"other": 1} + + +def test_merged_custom_data_replaces_an_existing_key(): + assert util.merged_custom_data({"mine": "old"}, "mine", "new") == {"mine": "new"} + + +def test_merged_custom_data_on_an_empty_column(): + assert util.merged_custom_data(None, "mine", 1) == {"mine": 1} + + +def test_merged_custom_data_removes_a_key(): + assert util.merged_custom_data({"mine": 1, "other": 2}, "mine", None) == {"other": 2} + + +def test_merged_custom_data_clears_the_column_when_nothing_is_left(): + # None is what the set commands read as "clear"; {} would be a wasted write + # of an empty object. + assert util.merged_custom_data({"mine": 1}, "mine", None) is None + + +def test_merged_custom_data_removing_a_key_that_is_not_there(): + assert util.merged_custom_data({"other": 2}, "mine", None) == {"other": 2} + + +def test_conn_status_reads_the_tag(): + contact = {"activeConn": {"connStatus": {"type": "ready"}}} + assert util.conn_status(contact) == "ready" + + +def test_conn_status_without_a_connection(): + # api_create_member_contact produces exactly this: a contact row before + # any connection exists. + assert util.conn_status({"contactId": 3}) is None + + +def test_conn_status_with_a_null_connection(): + assert util.conn_status({"activeConn": None}) is None + + +def test_check_profile_image_accepts_what_the_apps_decode(): + png = "data:image/png;base64,AAA" + jpg = "data:image/jpg;base64,AAA" + assert util.check_profile_image(png) == png + assert util.check_profile_image(jpg) == jpg + + +def test_check_profile_image_rejects_another_media_type(): + # image/jpeg is the easy mistake: the file extension is .jpeg, and the + # core stores it, but no client strips that prefix before decoding. + with pytest.raises(ValueError, match="must start with"): + util.check_profile_image("data:image/jpeg;base64,AAA") + + +def test_check_profile_image_rejects_a_remote_url(): + with pytest.raises(ValueError, match="must start with"): + util.check_profile_image("https://simplex.chat/logo.png") diff --git a/plans/2026-08-04-directory-link-approval.md b/plans/2026-08-04-directory-link-approval.md new file mode 100644 index 0000000000..6c92ca5bd9 --- /dev/null +++ b/plans/2026-08-04-directory-link-approval.md @@ -0,0 +1,100 @@ +# Directory: group link creation at approval + +Date: 2026-08-04 + +## Goal + +- The directory creates the group join link at first approval. +- The directory issues every link data update; the automatic refresh in core is disabled by config. +- The welcome message link requirement is replaced by a post-approval recommendation. +- A link sent to the directory is resolved to its registered group. + +Existing functions are amended; the diff is kept minimal, in code and in tests. + +## 1. Core (simplex-chat library) + +1.1. `ChatConfig`: add `updateGroupLinksFromApp :: Bool`, default `False`. The directory service sets `True` in `directoryService` and `directoryServiceCLI`. + +1.2. `xGrpInfo` (Subscriber.hs ~3750): condition before the fork: + +```haskell +ChatConfig {updateGroupLinksFromApp} <- asks config +unless (useRelays' g'' || updateGroupLinksFromApp) $ + void $ forkIO $ void $ setGroupLinkData' NRMBackground user g'' +``` + +`setGroupLinkData'` stays unchanged. The call in `runUpdateGroupProfile` (Commands.hs ~4043) stays unconditional. + +1.3. Link data sync from the directory: a Service.hs helper reads the link with `getGroupLink` and runs `setGroupLinkData NRMBackground user gInfo gLink` (Internal.hs ~1461, exported) via `runReaderT (runExceptT …) cc`; the `GroupInfo` argument supplies the profile. + +## 2. Registration flow (Service.hs) + +2.1. `deServiceJoinedGroup`: after `setGroupRegOwner` — set `GRSPendingApproval 1`, notify the owner ("Joined the group X. Registration is pending approval — it may take up to 48 hours."), send `recommendedSettingsNotice`, call `verifyAndSendToApprove`. The `APICreateGroupLink` call and the `GRSPendingUpdate` transition are removed. This mirrors the channel flow in `deMemberUpdated`. + +2.2. `DCApproveGroup`, after the duplicate and roles checks, before `setGroupStatusPromo`: + +- link record present (legacy registration or re-approval): the §1.3 sync with the `GroupInfo` from `getGroupAndReg`; +- link record absent: `APICreateGroupLink groupId GRMember`; on failure reply with the error and keep the status. + +Owner notification: approved, the link, "We recommend adding this link to the group welcome message." + +## 3. Profile update handling (`deGroupUpdated`, non-public groups) + +3.1. `GroupProfileUpdate` and `groupProfileUpdate` are replaced by one check — link-only change: fields other than description equal, descriptions equal after removal of the service link and the recommended phrase "Link to join the group :", with `T.words` normalization. The link is read with `APIGetGroupLink`; on `SEGroupLinkNotFound` the comparison runs without link removal; on other failures — log, no action (as today). The description-contains-link check (`profileGroupLinkText`, Service.hs ~641) moves to a helper shared with §6. + +3.2. Transitions. `n'` — n+1 when the status is `GRSPendingApproval n`, 1 otherwise. "Send to approve" — `checkRolesSendToApprove` as today. + +| status | change | status' | actions | +|---|---|---|---| +| GRSActive | link-only | GRSActive | notify owner; §1.3 sync with the event `toGroup` | +| GRSPendingApproval n | link-only | unchanged | — (the sent approval code stays valid) | +| GRSSuspended, GRSSuspendedBadRoles | link-only | unchanged | — | +| GRSPendingUpdate (legacy data only) | any | GRSPendingApproval 1 | notify owner; send to approve | +| any of the above | other change | GRSPendingApproval n' | notify owner and admins; send to approve | + +The `GRSPendingUpdate` branch of the `deGroupUpdated` dispatch (~533) is removed; the status is routed through `processProfileChange`. Link removal while active keeps the group listed; `GRSPendingUpdate` is unreachable for new registrations. Channel handling (`publicGroupProfileChange`) stays unchanged. + +## 4. Command replies (Service.hs) + +- `DCMemberRole`, group without a link: "The group link is created when the group is approved." +- `DCShowUpgradeGroupLink`: the `SEGroupLinkNotFound` reply mentions approval; the `APIAddGroupShortLink` upgrade branch requires `GRSActive`. +- `DCResumeGroup`, `DCSuspendGroup`, `DCApproveGroup` fallback replies include `groupRegStatusText`. +- `DCHelp DHSRegistration`: the welcome message step is replaced by approval; link inclusion is described as a post-approval recommendation. + +## 5. Search by link + +5.1. Detection: in `DCSearchGroup`, when the formatted text holds a `SimplexLink` of type `XLGroup` or `XLChannel`, the first such `simplexUri`, wrapped with `aConnectTarget`, is the lookup target. + +5.2. Lookup: `APIConnectPlan userId (Just target) PRMNever Nothing`: + +- `CPGroupLink (GLPOwnLink g)`, `CPGroupLink (GLPKnown {groupInfo})` → `getGroupReg` by group id; +- other plans, `CENotResolvedLocally` → unknown link. + +5.3. Replies: + +- user, `GRSActive` → the found-group message (single entry, existing format); +- user, other status or unknown link → the current not-found reply; +- admin, registered → group info with `groupRegStatusText` and owner, as in `sendGroupsInfo` admin format; +- admin, unknown link → "This link is not registered in the directory." + +5.4. Card path: `deChatLinkReceived` branches without a valid owner signature run the §5.2 lookup on `connLink`; `GLPKnown` → reply per §5.3; otherwise the current replies. + +## 6. Link in listings and search results + +6.1. Bot search results: `sendFoundGroups` appends the join-link line for non-public groups when the description omits the link; result rows are extended with the group link via `getGroupLink`. + +6.2. `groupDirectoryEntry` (Listing.hs): the join-link line, currently appended for public groups, is appended for non-public groups too when the description omits the link. + +## 7. Legacy registrations + +Registrations created before deployment keep their links. Their updates follow §3.2. Their approval syncs the link data (§2.2, first branch). + +## 8. Tests (DirectoryTests.hs) + +- Amend `submitGroup`, `groupAccepted`, `completeRegistrationId`, `updateProfileWithLink`, `notifySuperUser`, `approveRegistrationId` to the new sequence — submit → pending approval → approve → link in the approval notification — changing only the affected expected lines. +- New cases: link-only description change keeps the listing and syncs link data; content change requires re-approval; link-only change while pending keeps the approval code valid; profile change while suspended; legacy waiting-for-link registration moves to approval on profile change; `/resume` reply with status; `/role` and `/link` replies before approval; search by link as user and as admin; card from a non-owner for a listed group. + +## 9. Docs + +- `apps/simplex-directory-service/README.md`: registration steps and the state machine section. +- Bot `/help` text is covered by §4. diff --git a/plans/2026-08-13-auto-accept-group-invitations.md b/plans/2026-08-13-auto-accept-group-invitations.md new file mode 100644 index 0000000000..fdce3ebd2f --- /dev/null +++ b/plans/2026-08-13-auto-accept-group-invitations.md @@ -0,0 +1,149 @@ +# Auto-accept Group Invitations — Plan + +## Table of Contents +1. [Context](#1-context) +2. [Why This Belongs in the Core](#2-why-this-belongs-in-the-core) +3. [Design](#3-design) +4. [Decisions and Justification](#4-decisions-and-justification) +5. [Scope](#5-scope) +6. [Verification](#6-verification) + +--- + +## 1. Context + +**Problem**: Every group invitation requires the user to tap accept, even when they have +already decided they want to join groups from their contacts. Users in active communities +accumulate invitations that are pure friction — the decision was made when they added the +contact, not when the invitation arrived. + +**Precedent**: Privacy & security already carries a per-profile "Contact requests from +groups / Auto-accept" toggle (`users.auto_accept_member_contacts`, added in +`M20250729_member_contact_requests`). This change adds the equivalent for group +invitations — per-profile flag, same command pair — and regroups both under a single +**Auto-accept** section, so the two rows name what is being accepted rather than repeating +"Auto-accept" as two adjacent section headers: + +``` +Auto-accept + Contact requests in groups [ ] + Group invitations [ ] + These settings are for your current profile . +``` + +--- + +## 2. Why This Belongs in the Core + +The naive implementation is client-side: observe `CEvtReceivedGroupInvitation`, then call +`APIJoinGroup`. That is wrong here for three reasons. + +1. **It does not work when the app is closed.** Invitations arrive through the notification + extension and background message processing. A client-side rule cannot run there. +2. **`APIJoinGroup` blocks.** It takes `withGroupLock`, calls the agent's `joinConnection` + synchronously, and rolls member status back to `GSMemInvited` via `catchAllErrors` on + failure. Driving that from an event handler couples message processing to a network + round trip. +3. **It would be reimplemented per client.** Android, desktop and iOS would each carry the + decision, and they would drift. + +The flag therefore lives on `users`, and the decision is made where the invitation is +received. + +--- + +## 3. Design + +`processGroupInvitation` already contained an async accept path, used when an invitation +matches a group link the user opened: + +``` +prepareAgentJoin -> createMemberConnectionAsync -> joinAgentConnectionAsync +``` + +The outcome is reported later against the `CFJoinConn` command id, so nothing blocks. This +change does not add a second mechanism — it turns the existing two-way branch into three, +and auto-accept takes the path that already exists: + +| Condition | Behaviour | +|---|---| +| invitation matches an opened group link | join async, no chat item (unchanged) | +| profile auto-accepts, membership still `GSMemInvited` | join async, record accepted item | +| otherwise | create pending invitation item, notify (unchanged) | + +The shared sequence is extracted as `joinGroupAsync`; the invitation item as +`createInvitationItem`, parameterised by `CIGroupInvitationStatus` rather than a boolean. + +--- + +## 4. Decisions and Justification + +**Per-profile, not global.** Matches the sibling toggle and the user's mental model: a +profile is an identity, and willingness to auto-join groups is a property of that identity. +An incognito or work profile should not inherit a personal profile's setting. + +**A chat item is still recorded.** The group-link branch creates no item because the user +initiated the join. Auto-accept is not user-initiated, so a `CIRcvGroupInvitation` with +status `CIGISAccepted` is written to the chat with the inviting contact — a durable record +of who added the user to what. It is deliberately left counting as unread +(`ciRequiresAttention`), so an auto-join is noticed rather than silent. + +**`hostContact` is reported only for group links.** Clients respond to `hostContact` on +`CEvtUserAcceptedGroupSent` by replacing the transient host connection view with the group +and removing that chat. That is right for a group link, where the contact is a placeholder +created to join. For a plain invitation the contact is a real one, and removing their chat +would be destructive — so auto-accept passes `Nothing`, matching `APIJoinGroup`. + +**The join only runs while membership is `GSMemInvited`.** `createGroupInvitation` is +idempotent on `inv_queue_info`: a resent invitation returns the existing group rather than +failing. Without the guard, every resend would open another agent connection, which a +hostile or buggy host could drive indefinitely. A resend after joining is ignored. + +**UI strings reuse legacy keys deliberately.** The section header, the contact-requests row +and the footer use `auto_accept_contact`, `settings_section_title_contact_requests_from_groups` +and `receipts_section_description` — key names that no longer describe where they are used. +This is intentional: those keys are already translated in 35, 20 and 28 of 41 locales +respectively, while any newly added key ships English everywhere until translators catch up, +which would have put an English header and footer around a translated row. Only +`group_invitations` is genuinely new, so the feature adds exactly one string. Renaming these +keys to match their new use would discard the existing translations. All 28 translations of +`receipts_section_description` were checked and none mention receipts, so the reuse is safe. + +**Security note.** This converts a user-gated action into an automatic one: a contact can +cause the client to open group connections and fetch history without a prompt. It is +opt-in and per-profile, and channels cannot be used for it — `processGroupInvitation` +rejects `publicGroup` invitations outright. No rate cap is applied; the setting is +explicit. + +**Known behaviour.** Clients drop events for non-active profiles (`active(user)` guard), so +a group auto-accepted on a background profile becomes visible when switching to that +profile. The join itself happens on arrival: `subscribeUsers` passes the agent the active +user's id rather than the user list, and the agent enumerates every user's servers from its +own store — the active id orders subscriptions, it does not filter them. + +--- + +## 5. Scope + +| Layer | Change | +|---|---| +| Schema | `users.auto_accept_group_invitations` (`M20260813`, SQLite + Postgres) | +| Core | `User` field; `APISetUserAutoAcceptGroupInvitations` / `SetUserAutoAcceptGroupInvitations`; third branch in `processGroupInvitation` | +| Bot API | documented command; regenerated TypeScript/Python bindings and markdown | +| Android/desktop | Privacy & security: two per-profile toggles regrouped under one **Auto-accept** section; the contact-requests row relabelled "Contact requests in groups" | +| iOS | same section in `PrivacySettings.swift` | + +--- + +## 6. Verification + +- Schema dump, `.lint`, strict tables, and **down-migration round-trip** pass; the down + migration restores the original DDL byte-for-byte, so no skip-list entry is needed. +- JSON fixtures and all Bot API doc/codegen specs pass with no regenerated drift. +- `testGroupCheckMessages` and `testGroupLink` pass — the manual and group-link branches + are behaviour-preserving through the refactor. +- Three new tests: auto-accept on the active profile, on a second profile, and on an + inactive profile while another is active. + +**Not verified**: iOS is not compiled (no toolchain available); the Postgres schema test is +gated behind `#if defined(dbPostgres)` and was not run. diff --git a/plans/2026-08-18-desktop-video-preview-aspect-ratio.md b/plans/2026-08-18-desktop-video-preview-aspect-ratio.md new file mode 100644 index 0000000000..f3fa4bfe85 --- /dev/null +++ b/plans/2026-08-18-desktop-video-preview-aspect-ratio.md @@ -0,0 +1,73 @@ +# Desktop: video preview and playback stretched for AV1 + +## Problem + +Videos sent from the desktop app arrive with the wrong aspect ratio. It is most visible with +AV1, and only at some resolutions. The preview image sent with the message carries the wrong +dimensions, so the distortion is seen by every recipient on every platform, not only by the +sender. Desktop playback is distorted in the same way. + +## Cause + +The preview frame and the playback surface both come from `SkiaBitmapVideoSurface`, which +allocates its bitmap from the size libvlc passes to the vmem buffer format callback. + +That size is the size the decoder padded the picture to, not the size of the picture. dav1d +pads to a multiple of 128, so a 1920x1080 AV1 video is reported as 1920x1152. libvlc then sets +the visible area of the output format to whatever size the callback returns, which makes the +converter stretch the picture to fill it — the buffer holds a stretched frame, not a padded one. + +Measured against the bundled VLC 3.0.21, comparing the resulting bitmap with the true picture: + +| source | AV1 bitmap | error | H264 bitmap | error | +| ------------- | ---------- | --------------- | ----------- | ------ | +| 1920x1080 | 1920x1152 | 6.7% too tall | 1920x1090 | +0.9% | +| 1280x720 | 1280x768 | 6.7% too tall | 1280x738 | +2.5% | +| 640x360 | 640x384 | 6.7% too tall | 640x386 | +7.2% | +| 1080x1350 | 1152x1408 | 2.2% too wide | 1088x1378 | -1.3% | +| 1080x1080 | 1152x1152 | none | 1088x1090 | +0.2% | +| 1024x768 | 1024x768 | none | 1024x770 | +0.3% | + +Sizes already on the 128 grid are unaffected, which is why the report was "only at certain +dimensions". H264 pads much less, so it was there all along but barely visible. + +Android and iOS are not affected: they use `MediaMetadataRetriever` and return cropped frames. + +## Fix + +Ask libvlc for the size of the track being played instead of accepting the padded size, and +fall back to the padded size when the track is not known. The media player is captured in +`attach`, which always runs before the format callback, because it is what registers the +native callbacks in the first place. + +Two details the implementation depends on, both observed rather than assumed: + +- The format is negotiated more than once, and vlc has not selected the track on the first + calls (`video().track()` returns -1 there), so the single-track fallback is load-bearing. +- The first listed video track is not necessarily the one being decoded. Matching on the + playing track id keeps a file with several video tracks correct; an earlier revision using + the first track made such a file worse than before the fix (320x240 instead of 1920x1080). + +Fixing it at the buffer format keeps the preview and playback correct from one change, and +costs nothing: libvlc already runs a converter to fill the buffer, so it is only given the +right destination size. Rescaling the snapshot afterwards was considered and rejected — it +leaves playback distorted and resamples the frame twice. + +## Verification + +- The buffer produced with the fix is pixel identical to the frame decoded by ffmpeg + (PSNR inf) for both landscape and portrait clips. +- The real compiled class driven against the bundled VLC 3.0.21 produces correct bitmaps + where the current code does not: 1920x1152 -> 1920x1080, 1152x1408 -> 1080x1350, + 1280x768 -> 1280x720, 768x384 -> 641x361. +- 33 clips covering AV1/H264/VP9, mp4/mkv/webm, rotated, anamorphic, multi-track, cover art, + odd and 1x1 sizes, audio only and corrupt files, on both VLC 3.0.21 and 3.0.23. +- No buffer/format tearing across repeated, pooled and concurrent playback; with the fix every + negotiation returns the same size, where before they disagreed. + +## Out of scope + +- `getBitmapFromVideo` reads the orientation from the first video track and has the same + first-track assumption. +- Sample aspect ratio is ignored throughout, so anamorphic video is still shown with square + pixels. Unchanged by this fix. diff --git a/plans/2026-08-18-member-profile-open-in-large-groups.md b/plans/2026-08-18-member-profile-open-in-large-groups.md new file mode 100644 index 0000000000..2e5ccc3bbd --- /dev/null +++ b/plans/2026-08-18-member-profile-open-in-large-groups.md @@ -0,0 +1,150 @@ +# Open Member Profile Without Loading All Group Members + +## Context + +Tapping a member's avatar in chat history takes several seconds in a group with +10000 members, on every tap, on Android and desktop. + +**Root cause**: `showMemberInfo` (ChatView.kt:499) awaits three API calls before +showing the modal: + +1. `apiGroupMemberInfo` — single member, O(1) in group size +2. `apiGetGroupMemberCode` — single member, O(1) +3. `setGroupMembers` (ChatListNavLinkView.kt:254) — `apiListMembers`, the **whole + member list** + +Call 3 is the cost. `APIListMembers` (Commands.hs:3212) runs `getGroup`, which +loads every member with its profile (Groups.hs:938, 1222), the profile includes +the avatar (`p.image` in `groupMemberQuery`, Shared.hs:762), and the result is +encoded to JSON, passed across the FFI boundary and decoded into 10000 +`GroupMember` objects by the client. The codebase already annotates this call as +"very heavy query in large groups" (SimpleXAPI.kt:846). + +There is no `membersLoaded` guard on this call, unlike `GroupMentions.kt:116`, so +the full list is re-loaded on *every* tap even when it is already in the model. + +Measured on a 10009-member group (real member rows, half with a 11.7 KB avatar): +the SQL itself takes 0.21 s and returns ~64 MB of column data (5.1 MB without +avatars). The seconds are the JSON encode/transfer/decode of that payload. + +The full member list is not needed to show one member's profile. It is loaded +only so that `chatModel.getGroupMember` (ChatModel.kt:357) resolves the member +for the modal (ChatView.kt:521) and for the "Verify security code" screen +(GroupMemberInfoView.kt:209). + +## Solution Summary + +Do not load the member list on this path. Add the opened member to the model +instead, and show the modal — this is what the iOS app already does +(ChatView.swift:2051-2058, since 03bc4e5d0, "ios: display reactions in groups by +member"). The Kotlin path was never updated to match. + +```kotlin +val (updatedMember, code) = if (member.memberActive) { + val memCode = chatModel.controller.apiGetGroupMemberCode(...) + (memCode?.first ?: r?.first ?: member) to memCode?.second +} else { + (r?.first ?: member) to null +} +if (!isActive || chatModel.chatId.value != groupInfo.id) return@launch +withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember) +} +``` + +After the change the tap runs two single-row queries. Measured against the core +with 10009 members and 100035 messages in the group: `APIGroupMemberInfo` takes +1-2 ms, first call included, and does not depend on group size. + +## Technical Design + +### Which member is added to the model + +The member returned by `apiGetGroupMemberCode` is preferred over the one from +`apiGroupMemberInfo`. `APIGetGroupMemberCode` (Commands.hs:2016) clears +verification in the database when the peer's security code no longer matches +(`setGroupMemberVerified ... Nothing` / `setConnectionVerified ... Nothing`) and +returns the updated member. `apiGroupMemberInfo` runs before that, so its member +can still show the connection as verified. The previous code re-read all members +from the database *after* the code call, so the model saw the cleared state; +using the code call's member preserves that behaviour, and the verified shield +(GroupMemberInfoView.kt:736) does not go stale. + +### Guard on the open chat + +`upsertGroupMember` (ChatModel.kt:927) is a no-op when the open chat changed +while the two calls were in flight, which would leave `getGroupMember` null and +open an empty card. The explicit `chatModel.chatId.value != groupInfo.id` check +closes the modal path in that case instead. The previous code filled the model in +that race by writing the *previous* group's members into it — the stale data +hazard that `upsertGroupMember`'s own comment warns about (ChatModel.kt:936). + +### Duplicate protection is retained + +`#5462` ("improving group members loading to prevent crashes") made the wholesale +replacement safe against duplicated entries crashing `LazyColumn`. +`upsertGroupMember` carries the same protection: it clears the list when the +first member belongs to another group (ChatModel.kt:936) and looks the member up +by index before appending (ChatModel.kt:940, 956-966). + +## Consequences + +`chatModel.groupMembers` can now hold a partial list (previously it was either +empty or complete). This state already existed — channel creation writes a +relays-only list (ComposeView.kt:693) — and `membersLoaded` is deliberately left +`false`, so every screen that needs the full list still loads it: +`GroupChatInfoView.kt:117`, `ChannelMembersView`, `ChannelRelaysView.kt:38`, +`MemberSupportView.kt:45`, `addGroupMembers` (ChatView.kt:3217), and +`GroupMentions.kt:116` which checks the flag. + +One behaviour changes: + +- **Mention picker.** Until `@` triggers the load, the picker briefly lists the + members opened so far instead of nothing. Self-correcting. + +The relay removal warning is *not* affected. `activeRelays.size <= 1` +(GroupMemberInfoView.kt:250, GroupChatInfoView.kt:277) is computed from the +model, so it is already conservative while the member list is loading — with an +empty list the count is 0 and the warning fires. A partial list is a subset, so +the count can only move towards the correct value, and the opposite error is +impossible: a subset with two active relays implies the group has at least two. +It is also not reachable from the modified path — messages delivered through +relays have no item member (`CDChannelRcv`, Subscriber.hs:2161, `chatItemMember` +returns Nothing, Messages.hs:375) and their avatar opens chat info +(ChatView.kt:2150), not member info. A relay's profile is opened from the +channel members and relays screens, which load all members themselves +(GroupChatInfoView.kt:117, ChannelRelaysView.kt:38). + +The bulk refresh of all members that happened as a side effect of every tap is +gone; each screen refreshes its own data on open. + +## Alternatives Rejected + +- **Guard the load with `!membersLoaded`** — cures repeat taps, leaves the first + tap in a group costing seconds. +- **No model write, fall back to the chat item's member in the modal** — smaller + diff, but "Verify security code" (GroupMemberInfoView.kt:209) resolves the + member through the model too and would open empty; it would need a second + fallback in another file. +- **Move `apiGroupMemberInfo`/`apiGetGroupMemberCode` into `GroupMemberInfoView` + behind a `connectionLoaded` gate (full iOS parity, GroupMemberInfoView.swift:291)** — + opens the card with no API calls at all, but changes the view's signature, three + call sites and two previews, and makes the connection rows appear after the card + on every open. Worth doing separately; it does not affect the cost removed here. + +## Out of Scope + +- The first profile opened after entering a large group is still slower than the + rest. The core is not the cause (1-2 ms measured, agent call included); the tap + waits for the UI thread, which is busy composing the just-loaded page of + messages (`MergedItems.create` over all loaded items, ChatView.kt:1800) and + decoding avatars. Fixing it means making chat opening cheaper, or adding the + member to the model synchronously in the click handler as iOS does. +- Opening a second member's profile while one is already open does not switch the + card: `showInView` drives `AnimatedContent` from `modalCount` alone + (ModalView.kt:207), while `modalViews` is a plain list, so a close and open in + one frame leaves the target state unchanged. Pre-existing, unrelated to this + change. +- Message *Info* (ChatView.kt:706) still loads all members; it needs them to + resolve delivery recipients (ChatItemInfoView.kt:550). A narrower core API + would be required. diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index e6ecc7478d..ca55a08383 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,23 @@ + + https://simplex.chat/blog/20260722-simplex-public-names.html + +

New in v7.0-v7.0.1.

+

SimpleX public names for channels and businesses (BETA).

+

Better channels:

+
    +
  • Promote subscribers to contributors.
  • +
  • Publish your channel on your website.
  • +
  • Verify security code with contributors.
  • +
  • Wider messages, easier to read.
  • +
  • Host your own chat relays.
  • +
+

Add a longer description to your profile.

+

Simplified app settings.

+
+
https://simplex.chat/blog/20260722-simplex-public-names.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 4c6086dabb..f1456b7143 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."413f30bee5a14312eca67713b5c47a3537a0c028" = "07izzz8ddcnmnk2bzcid6kf4dgln4zqkjlcsp2aqhnlgw2qh7pph"; + "https://github.com/simplex-chat/simplexmq.git"."0d3cf39c27aec1c51c88d7bf3d6762bc22278967" = "115m1h8pc4wdbd5y1prz7qgqnxhsin1f6m53k4szab9c1iawlf4v"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 404456b122..63db5d040c 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 7.1.0.2 +version: 7.1.0.3 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -153,6 +153,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection + Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations else exposed-modules: Simplex.Chat.Archive @@ -323,6 +324,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection + Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 69e271e927..cd44b69df6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -137,6 +137,7 @@ defaultChatConfig = relayRequestExpiry = (10, nominalDay), deviceNameForRemote = "", remoteCompression = True, + updateGroupLinksFromApp = False, chatHooks = defaultChatHooks } diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e8392b8c42..54dfb58fc3 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -174,6 +174,7 @@ data ChatConfig = ChatConfig highlyAvailable :: Bool, deviceNameForRemote :: Text, remoteCompression :: Bool, + updateGroupLinksFromApp :: Bool, chatHooks :: ChatHooks } @@ -339,6 +340,8 @@ data ChatCommand | SetUserGroupReceipts UserMsgReceiptSettings | APISetUserAutoAcceptMemberContacts {userId :: UserId, onOff :: Bool} | SetUserAutoAcceptMemberContacts Bool + | APISetUserAutoAcceptGroupInvitations {userId :: UserId, onOff :: Bool} + | SetUserAutoAcceptGroupInvitations Bool | APIHideUser UserId UserPwd | APIUnhideUser UserId UserPwd | APIMuteUser UserId @@ -695,6 +698,11 @@ planResolveModeP = "never" -> pure PRMNever _ -> fail "bad PlanResolveMode" +data CommandSource + = CSLocal -- entered on this device + | CSRemoteHost RemoteHostId -- forwarded to a paired remote host + | CSRemoteCtrl -- received from a paired remote controller + allowRemoteCommand :: ChatCommand -> Bool -- XXX: consider using Relay/Block/ForceLocal allowRemoteCommand = \case StartChat {} -> False diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index c382a6dc8e..f5f7f581df 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -97,7 +97,7 @@ runSimplexChat ChatConfig {testView} ChatOpts {coreOptions = CoreChatOpts {chatR waitEither_ a1 a2 sendChatCmdStr :: ChatController -> String -> IO (Either ChatError ChatResponse) -sendChatCmdStr cc s = runReaderT (execChatCommand Nothing (encodeUtf8 $ T.pack s) 0) cc +sendChatCmdStr cc s = runReaderT (execChatCommand CSLocal (encodeUtf8 $ T.pack s) 0) cc sendChatCmd :: ChatController -> ChatCommand -> IO (Either ChatError ChatResponse) sendChatCmd cc cmd = runReaderT (execChatCommand' cmd 0) cc diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 8a41e7c655..d98b387097 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -378,19 +378,24 @@ useServers as opDomains uss = xftp' = useServerCfgs SPXFTP as opDomains $ concatMap (servers' SPXFTP) uss in (smp', xftp') -execChatCommand :: Maybe RemoteHostId -> ByteString -> Int -> CM' (Either ChatError ChatResponse) -execChatCommand rh s retryNum = +execChatCommand :: CommandSource -> ByteString -> Int -> CM' (Either ChatError ChatResponse) +execChatCommand src s retryNum = case parseChatCommand s of Left e -> pure $ chatCmdError e - Right cmd -> case rh of - Just rhId + Right cmd -> case src of + CSRemoteHost rhId | allowRemoteCommand cmd -> execRemoteCommand rhId cmd s retryNum | otherwise -> pure $ Left $ ChatErrorRemoteHost (RHId rhId) $ RHELocalCommand - _ -> do - cc@ChatController {config = ChatConfig {chatHooks}} <- ask - case preCmdHook chatHooks of - Just hook -> liftIO (hook cc cmd) >>= either pure (`execChatCommand'` retryNum) - Nothing -> execChatCommand' cmd retryNum + CSRemoteCtrl + | allowRemoteCommand cmd -> execLocal cmd + | otherwise -> pure $ Left $ ChatErrorRemoteCtrl $ RCEProtocolError $ RPEInvalidBody "prohibited command" + CSLocal -> execLocal cmd + where + execLocal cmd = do + cc@ChatController {config = ChatConfig {chatHooks}} <- ask + case preCmdHook chatHooks of + Just hook -> liftIO (hook cc cmd) >>= either pure (`execChatCommand'` retryNum) + Nothing -> execChatCommand' cmd retryNum execChatCommand' :: ChatCommand -> Int -> CM' (Either ChatError ChatResponse) execChatCommand' cmd retryNum = handleCommandError $ do @@ -501,6 +506,12 @@ processChatCommand cxt nm = \case withFastStore' $ \db -> updateUserAutoAcceptMemberContacts db user' onOff ok user SetUserAutoAcceptMemberContacts onOff -> withUser $ \User {userId} -> processChatCommand cxt nm $ APISetUserAutoAcceptMemberContacts userId onOff + APISetUserAutoAcceptGroupInvitations userId' onOff -> withUser $ \user -> do + user' <- privateGetUser userId' + validateUserPassword user user' Nothing + withFastStore' $ \db -> updateUserAutoAcceptGroupInvitations db user' onOff + ok user + SetUserAutoAcceptGroupInvitations onOff -> withUser $ \User {userId} -> processChatCommand cxt nm $ APISetUserAutoAcceptGroupInvitations userId onOff APIHideUser userId' (UserPwd viewPwd) -> withUser $ \user -> do user' <- privateGetUser userId' case viewPwdHash user' of @@ -1692,7 +1703,7 @@ processChatCommand cxt nm = \case Just RelayAddressLinkData {relayProfile} -> do let failWithProfile step e = pure $ CRChatRelayTestResult user (Just relayProfile) (Just $ RelayTestFailure step e) - lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + lift (withAgent' (`connRequestAgentVersion` cReq)) >>= \case Nothing -> failWithProfile RTSConnect (ChatError $ CERelayTestError "invalid connection request") Just _ -> do let chatV = initialChatVersion @@ -3373,7 +3384,7 @@ processChatCommand cxt nm = \case _ -> throwChatError $ CEException "connection already started (past prepared status)" where joinNewConn subMode = do - -- possible improvement: use agent connRequestPQSupport to determine pqSupport here; + -- possible improvement: use agent connRequestAgentVersion to determine pqSupport here; -- for joinPreparedConn below - same + encodeConnInfoPQ; -- same for auto-accept on xGrpDirectInv acId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff @@ -3603,7 +3614,7 @@ processChatCommand cxt nm = \case ConfirmRemoteCtrl rcId -> withUser_ $ do (rc, ctrlAppInfo) <- confirmRemoteCtrl rcId pure CRRemoteCtrlConnecting {remoteCtrl_ = Just rc, ctrlAppInfo, appVersion = currentAppVersion} - VerifyRemoteCtrlSession sessId -> withUser_ $ verifyRemoteCtrlSession (execChatCommand Nothing) sessId + VerifyRemoteCtrlSession sessId -> withUser_ $ verifyRemoteCtrlSession (execChatCommand CSRemoteCtrl) sessId StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ @@ -3761,10 +3772,10 @@ processChatCommand cxt nm = \case connectViaInvitation user@User {userId} incognito (CCLink cReq@(CRInvitationUri crData e2e) sLnk_) contactId_ = withInvitationLock "connect" (strEncode cReq) $ do subMode <- chatReadVar subscriptionMode - lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOn cReq) >>= \case + lift (withAgent' (`connRequestAgentVersion` cReq)) >>= \case Nothing -> throwChatError CEInvalidConnReq -- TODO PQ the error above should be CEIncompatibleConnReqVersion, also the same API should be called in Plan - Just (_, pqSup') -> do + Just _ -> do let chatV = initialChatVersion withFastStore' (\db -> getConnectionEntityByConnReq db cxt user cReqs) >>= \case Nothing -> joinNewConn chatV @@ -3775,6 +3786,8 @@ processChatCommand cxt nm = \case joinPreparedConn conn (fromLocalProfile <$> localIncognitoProfile) Just ent -> throwCmdError $ "connection is not RcvDirectMsgConnection: " <> show (connEntityInfo ent) where + -- all supported versions support PQ encryption + pqSup' = PQSupportOn joinNewConn chatV = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing @@ -3921,10 +3934,7 @@ processChatCommand cxt nm = \case _ -> pure () prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat) prepareContact user cReq pqSup = do - -- 0) toggle disabled - PQSupportOff - -- 1) toggle enabled, address supports PQ (connRequestPQSupport returns Just True) - PQSupportOn, enable support with compression - -- 2) toggle enabled, address doesn't support PQ - PQSupportOn but without compression, with version range indicating support - lift (withAgent' $ \a -> connRequestPQSupport a pqSup cReq) >>= \case + lift (withAgent' (`connRequestAgentVersion` cReq)) >>= \case Nothing -> throwChatError CEInvalidConnReq Just _ -> do let chatV = initialChatVersion @@ -4254,7 +4264,7 @@ processChatCommand cxt nm = \case addRelay :: UserChatRelay -> CM (UserChatRelay, Either ChatError GroupRelay) addRelay relay@UserChatRelay {address} = fmap (relay,) . tryAllErrors $ do (_, _, cReq) <- getShortLinkConnReq nm user address - lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + lift (withAgent' (`connRequestAgentVersion` cReq)) >>= \case Nothing -> throwChatError CEInvalidConnReq Just _ -> do let chatV = initialChatVersion @@ -5429,6 +5439,8 @@ chatCommandP = "/set receipts groups " *> (SetUserGroupReceipts <$> receiptSettings), "/_set accept member contacts " *> (APISetUserAutoAcceptMemberContacts <$> A.decimal <* A.space <*> onOffP), "/set accept member contacts " *> (SetUserAutoAcceptMemberContacts <$> onOffP), + "/_set accept group invitations " *> (APISetUserAutoAcceptGroupInvitations <$> A.decimal <* A.space <*> onOffP), + "/set accept group invitations " *> (SetUserAutoAcceptGroupInvitations <$> onOffP), "/_hide user " *> (APIHideUser <$> A.decimal <* A.space <*> jsonP), "/_unhide user " *> (APIUnhideUser <$> A.decimal <* A.space <*> jsonP), "/_mute user " *> (APIMuteUser <$> A.decimal), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index ca6440d155..aa6974a7de 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2940,8 +2940,7 @@ connRequestPQEncryption = \case CRContactUri _ rks -> pqEnc . snd <$> rks CRInvitationUri _ e2e -> Just $ pqEnc e2e where - pqEnc (CR.E2ERatchetParamsUri vr' _ _ pq) = - PQEncryption $ maxVersion vr' >= CR.pqRatchetE2EEncryptVersion && isJust pq + pqEnc (CR.E2ERatchetParamsUri _ _ _ pq) = PQEncryption $ isJust pq createRcvFeatureItems :: User -> Contact -> Contact -> CM' () createRcvFeatureItems user ct ct' = diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 6e798f7ab2..79ce572785 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1201,7 +1201,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = case cReq of CRContactUri crData@ConnReqUriData {crClientData} e2e -> do let pqSup = PQSupportOff - lift (withAgent' $ \a -> connRequestPQSupport a pqSup cReq) >>= \case + lift (withAgent' (`connRequestAgentVersion` cReq)) >>= \case Nothing -> throwChatError CEInvalidConnReq Just _ -> do let chatV = initialChatVersion @@ -2618,24 +2618,35 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let GroupMember {groupMemberId, memberId = membershipMemId} = membership - if sameGroupLinkId groupLinkId groupLinkId' - then do - subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo $ XGrpAcpt membershipMemId - connIds@(cmdId, acId) <- prepareAgentJoin user Nothing True connRequest - withStore' $ \db -> do - setViaGroupLinkUri db groupId connId - createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode - updateGroupMemberStatusById db userId hostId GSMemAccepted - updateGroupMemberStatus db userId membership GSMemAccepted - joinAgentConnectionAsync cmdId False acId True connRequest dm subMode - toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) - else do - let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole - (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content - withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] - toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} + -- hostContact is only reported for group links, where the client replaces + -- the transient host connection view with the group and removes its chat + joinGroupAsync hostContact_ sameLink = do + subMode <- chatReadVar subscriptionMode + dm <- encodeConnInfo $ XGrpAcpt membershipMemId + connIds@(cmdId, acId) <- prepareAgentJoin user Nothing True connRequest + withStore' $ \db -> do + when sameLink $ setViaGroupLinkUri db groupId connId + createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode + updateGroupMemberStatusById db userId hostId GSMemAccepted + updateGroupMemberStatus db userId membership GSMemAccepted + joinAgentConnectionAsync cmdId False acId True connRequest dm subMode + toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} hostContact_ + createInvitationItem invStatus = do + let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = invStatus}) memRole + (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content + withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] + if + | sameGroupLinkId groupLinkId groupLinkId' -> + joinGroupAsync (Just ct) True + | isTrue (autoAcceptGroupInvitations user) -> + -- a resent invitation returns the existing group, so only join while still invited + when (memberStatus membership == GSMemInvited) $ do + joinGroupAsync Nothing False + createInvitationItem CIGISAccepted + | otherwise -> do + createInvitationItem CIGISPending + toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} where GroupInvitation {groupProfile = GroupProfile {publicGroup}} = inv brokerTs = metaBrokerTs msgMeta @@ -3707,7 +3718,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' -- in channels, link data is updated by the owner making the change in runUpdateGroupProfile; -- other owners receiving the update do not refresh the same link - unless (useRelays' g'') $ + ChatConfig {updateGroupLinksFromApp} <- asks config + unless (useRelays' g'' || updateGroupLinksFromApp) $ void $ forkIO $ void $ setGroupLinkData' NRMBackground user g'' Just _ -> updateGroupPrefs_ msgSigned g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' -- relay advertises its web capability now that the owner's version is known (bumped by saveGroupRcvMsg) diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index 9c0ed521c7..1f9ae0b9ea 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -63,7 +63,7 @@ batchMessages mode maxLen = addBatch . foldr addToBatch ([], [], [], 0, 0) addToBatch :: Either ChatError SndMessage -> ([Either ChatError MsgBatch], [ByteString], [SndMessage], Int, Int) -> ([Either ChatError MsgBatch], [ByteString], [SndMessage], Int, Int) addToBatch (Left err) acc = (Left err : addBatch acc, [], [], 0, 0) -- step over original error addToBatch (Right msg@SndMessage {msgBody, signedMsg_}) acc@(batches, bodies, msgs, len, n) - | batchLen mode len' n' <= maxLen = (batches, body : bodies, msg : msgs, len', n') + | n' <= maxBatchElementCount && batchLen mode len' n' <= maxLen = (batches, body : bodies, msg : msgs, len', n') | msgLen <= maxLen = (addBatch acc, [body], [msg], msgLen, 1) | otherwise = (errLarge msg : addBatch acc, [], [], 0, 0) where @@ -90,7 +90,7 @@ batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) | msgLen + 4 > maxLen = (msgBodies, accepted, task : large, len, n) -- fits: include in batch -- batch overhead: '=' + count (2) + 2-byte length prefix per element - | len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, task : accepted, large, len', n + 1) + | n + 1 <= maxBatchElementCount && len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, task : accepted, large, len', n + 1) -- doesn't fit: stop adding further messages | otherwise = (msgBodies, accepted, large, len, n) where @@ -112,7 +112,7 @@ batchElements maxLen = finish . foldl' addToBatch ([], [], 0, 0, 0) where addToBatch (batches, elems, len, n, dropped) el | elLen + 4 > maxLen = (batches, elems, len, n, dropped + 1) - | len + elLen + (n + 1) * 2 + 2 <= maxLen = (batches, el : elems, len + elLen, n + 1, dropped) + | n + 1 <= maxBatchElementCount && len + elLen + (n + 1) * 2 + 2 <= maxLen = (batches, el : elems, len + elLen, n + 1, dropped) | otherwise = (closeBatch elems : batches, [el], elLen, 1, dropped) where elLen = B.length el @@ -182,7 +182,7 @@ batchProfilesWithBody maxLen body labeled = initState = (initLen, initCount, [], [], []) step (totalLen, count, acceptedPairs, overflow, large) (s, e) | B.length e + 4 > maxLen = (totalLen, count, acceptedPairs, overflow, s : large) - | count >= 255 = full + | count >= maxBatchElementCount = full | candidateLen <= maxLen = (candidateLen, count + 1, (s, e) : acceptedPairs, overflow, large) | otherwise = full where @@ -215,7 +215,7 @@ batchProfiles maxLen = addToBatch (s, e) acc@(batches, elems, members, len, n, large) | B.length e + 4 > maxLen = (batches, elems, members, len, n, s : large) -- batch overhead: '=' + count (2) + 2-byte length prefix per element - | n + 1 <= 255 && len + B.length e + (n + 1) * 2 + 2 <= maxLen = + | n + 1 <= maxBatchElementCount && len + B.length e + (n + 1) * 2 + 2 <= maxLen = (batches, e : elems, s : members, len + B.length e, n + 1, large) -- doesn't fit current — flush and start new with this element alone | otherwise = diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index fbf2a226e1..09af069ef7 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -352,7 +352,7 @@ chatSendCmd cc cmd = chatSendRemoteCmdRetry cc Nothing cmd 0 {-# INLINE chatSendCmd #-} chatSendRemoteCmdRetry :: ChatController -> Maybe RemoteHostId -> B.ByteString -> Int -> IO JSONByteString -chatSendRemoteCmdRetry cc rh s retryNum = J.encode . eitherToResult rh <$> runReaderT (execChatCommand rh s retryNum) cc +chatSendRemoteCmdRetry cc rh s retryNum = J.encode . eitherToResult rh <$> runReaderT (execChatCommand (maybe CSLocal CSRemoteHost rh) s retryNum) cc chatRecvMsg :: ChatController -> IO JSONByteString chatRecvMsg ChatController {outputQ} = J.encode . uncurry eitherToResult <$> readChatResponse diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 8d9c8ecfcc..2f57140200 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BangPatterns #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DerivingStrategies #-} @@ -909,6 +910,10 @@ maxCompressedMsgLength = 13380 maxDecompressedMsgLength :: Int maxDecompressedMsgLength = 65536 +-- Applies to all batch formats; 255 is the maximum for the 1-byte count in the binary batch format. +maxBatchElementCount :: Int +maxBatchElementCount = 255 + -- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the -- promotion cap over the promoted (member/moderator/admin) set. maxGroupRosterSize :: Int @@ -953,10 +958,17 @@ encodeChatMessage maxSize msg = do parseChatMessages :: ByteString -> [Either String AParsedMsg] parseChatMessages "" = [Left "empty string"] -parseChatMessages msg = case B.head msg of +parseChatMessages msg = checkBatchLimit $ case B.head msg of 'X' -> decodeCompressed (B.tail msg) c -> parseUncompressed c msg where + checkBatchLimit ms + | ms `lengthLE` maxBatchElementCount = ms + | otherwise = [Left "too many messages in batch"] + -- defined prefix: GHC 8.10 does not parse a bang operand in an infix definition + lengthLE :: [a] -> Int -> Bool + lengthLE [] !n = n >= 0 + lengthLE (_ : xs) !n = n > 0 && lengthLE xs (n - 1) parseUncompressed c s = case c of '[' -> case J.eitherDecodeStrict' s of Right v -> map (fmap plainMsg . parseItem) v diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 0e23cc795c..991218367a 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -553,7 +553,7 @@ liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) handleSend :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> Text -> Int -> CM' RemoteResponse handleSend execCC command retryNum = do logDebug $ "Send: " <> tshow command - -- execCC checks for remote-allowed commands + -- execCC is execChatCommand CSRemoteCtrl, which checks allowRemoteCommand -- convert errors thrown in execCC into error responses to prevent aborting the protocol wrapper RRChatResponse . eitherToResult <$> execCC (encodeUtf8 command) retryNum diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 7ddcc08a58..53d5cd619e 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -199,7 +199,7 @@ createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xConta -- TODO (proposed): -- - add agent version 8 for short links -- - update agentToChatVersion to convert 8 to 16 - -- - return and correctly set peer's range from link (via connRequestPQSupport) + -- - return and correctly set peer's range from link (via connRequestAgentVersion) peerChatVRange = chatInitialVRange, -- this is 1-1 connLevel = 0, viaContact = Nothing, diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 19c07edbf8..62021bbe4b 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -46,6 +46,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260715_profile_description import Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history import Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection +import Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -91,7 +92,8 @@ schemaMigrations = ("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description), ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history), ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles), - ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection) + ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), + ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260813_auto_accept_group_invitations.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260813_auto_accept_group_invitations.hs new file mode 100644 index 0000000000..fa1e621619 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260813_auto_accept_group_invitations.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260813_auto_accept_group_invitations :: Text +m20260813_auto_accept_group_invitations = + [r| +ALTER TABLE users ADD COLUMN auto_accept_group_invitations SMALLINT NOT NULL DEFAULT 0; +|] + +down_m20260813_auto_accept_group_invitations :: Text +down_m20260813_auto_accept_group_invitations = + [r| +ALTER TABLE users DROP COLUMN auto_accept_group_invitations; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index ae695d3c37..0978a54115 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1510,7 +1510,8 @@ CREATE TABLE test_chat_schema.users ( active_order bigint DEFAULT 0 NOT NULL, auto_accept_member_contacts smallint DEFAULT 0 NOT NULL, is_user_chat_relay smallint DEFAULT 0 NOT NULL, - client_service smallint DEFAULT 0 NOT NULL + client_service smallint DEFAULT 0 NOT NULL, + auto_accept_group_invitations smallint DEFAULT 0 NOT NULL ); diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index ce6a8c4c9f..965cbcbc78 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -42,6 +42,7 @@ module Simplex.Chat.Store.Profiles updateUserContactReceipts, updateUserGroupReceipts, updateUserAutoAcceptMemberContacts, + updateUserAutoAcceptGroupInvitations, updateUserProfile, setUserBadge, setUserProfileContactLink, @@ -135,19 +136,22 @@ import Database.SQLite.Simple.QQ (sql) createUserRecordAt :: DB.Connection -> AgentUserId -> Bool -> Bool -> Profile -> Bool -> UTCTime -> ExceptT StoreError IO User createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {displayName, fullName, shortDescr, description, image, peerType, preferences = userPreferences} activeUser currentTs = checkConstraint SEDuplicateName . liftIO $ do - when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0" let showNtfs = True sendRcptsContacts = True sendRcptsSmallGroups = True autoAcceptMemberContacts = False + autoAcceptGroupInvitations = False order <- getNextActiveOrder db DB.execute db - "INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?)" + "INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, auto_accept_group_invitations, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?)" ( (auId, displayName, BI activeUser, BI userChatRelay, order) - :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI clientService, currentTs, currentTs) + :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, BI clientService, currentTs, currentTs) ) userId <- insertedRowId db + -- After the insert: the name is unique in users, so a duplicate fails + -- above, and deactivating first would commit a database with no active user. + when activeUser $ DB.execute db "UPDATE users SET active_user = 0 WHERE user_id != ?" (Only userId) DB.execute db "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" @@ -163,7 +167,7 @@ createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {di (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing) + pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing) -- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] @@ -328,6 +332,10 @@ updateUserAutoAcceptMemberContacts :: DB.Connection -> User -> Bool -> IO () updateUserAutoAcceptMemberContacts db User {userId} autoAccept = DB.execute db "UPDATE users SET auto_accept_member_contacts = ? WHERE user_id = ?" (BI autoAccept, userId) +updateUserAutoAcceptGroupInvitations :: DB.Connection -> User -> Bool -> IO () +updateUserAutoAcceptGroupInvitations db User {userId} autoAccept = + DB.execute db "UPDATE users SET auto_accept_group_invitations = ? WHERE user_id = ?" (BI autoAccept, userId) + updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User updateUserProfile db user p' | displayName == newName = liftIO $ do @@ -338,12 +346,14 @@ updateUserProfile db user p' | otherwise = checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime - DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId) - userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs + -- Insert first: checkConstraint returns the violation as a value, so the + -- transaction commits, keeping whatever ran before the failing insert. DB.execute db "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" (newName, newName, userId, currentTs, currentTs) + DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId) + userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs updateUserProfileFields_' db userId profileId p' currentTs updateContactLDN_ db user userContactId localDisplayName newName currentTs pure user {localDisplayName = newName, profile = (toLocalProfile profileId p' localAlias currentTs (Just False) Nothing) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index a4e7ab04a2..cb046f6bc5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -169,6 +169,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260715_profile_description import Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history import Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection +import Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -337,7 +338,8 @@ schemaMigrations = ("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description), ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history), ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles), - ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection) + ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), + ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260813_auto_accept_group_invitations.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260813_auto_accept_group_invitations.hs new file mode 100644 index 0000000000..f0fab81d68 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260813_auto_accept_group_invitations.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260813_auto_accept_group_invitations :: Query +m20260813_auto_accept_group_invitations = + [sql| +ALTER TABLE users ADD COLUMN auto_accept_group_invitations INTEGER NOT NULL DEFAULT 0; +|] + +down_m20260813_auto_accept_group_invitations :: Query +down_m20260813_auto_accept_group_invitations = + [sql| +ALTER TABLE users DROP COLUMN auto_accept_group_invitations; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 4438182941..509b9b35f4 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -691,6 +691,15 @@ Query: Plan: +Query: + INSERT INTO snd_message_deliveries (conn_id, snd_queue_id, internal_id) + SELECT conn_id, ?, internal_id + FROM snd_message_deliveries + WHERE conn_id = ? AND snd_queue_id = ? AND failed = 0 + +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=? AND snd_queue_id=? AND failed=?) + Query: INSERT INTO snd_messages ( conn_id, internal_snd_id, internal_id, internal_hash, previous_msg_hash, msg_encrypt_key, padded_msg_len, snd_message_body_id) @@ -1221,6 +1230,10 @@ Query: SELECT count(1) FROM snd_message_bodies Plan: SCAN snd_message_bodies +Query: SELECT count(1) FROM snd_message_deliveries WHERE conn_id = ? AND snd_queue_id = ? AND failed = 0 +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=? AND snd_queue_id=? AND failed=?) + Query: SELECT count(1) FROM users Plan: SCAN users diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 6884bf7e04..2edce7cc3c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -6185,7 +6185,7 @@ SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6198,7 +6198,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6212,7 +6212,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6226,7 +6226,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6241,7 +6241,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6255,7 +6255,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6269,7 +6269,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6283,7 +6283,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6297,7 +6297,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6310,7 +6310,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -7069,7 +7069,7 @@ Plan: Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: -Query: INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?) +Query: INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, auto_accept_group_invitations, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?) Plan: Query: INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?) @@ -7931,10 +7931,18 @@ Query: UPDATE users SET active_user = 0 Plan: SCAN users +Query: UPDATE users SET active_user = 0 WHERE user_id != ? +Plan: +SCAN users + Query: UPDATE users SET active_user = 1, active_order = ? WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE users SET auto_accept_group_invitations = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE users SET auto_accept_member_contacts = ? WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index cfbcf70599..a49a6d0db7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -53,7 +53,8 @@ CREATE TABLE users( active_order INTEGER NOT NULL DEFAULT 0, auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0, is_user_chat_relay INTEGER NOT NULL DEFAULT 0, - client_service INTEGER NOT NULL DEFAULT 0, -- 1 for active user + client_service INTEGER NOT NULL DEFAULT 0, + auto_accept_group_invitations INTEGER NOT NULL DEFAULT 0, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE RESTRICT diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 6516b3cc66..ceaf905df0 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -557,20 +557,21 @@ userQuery :: Query userQuery = [sql| SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User -toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) = - User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes} +toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User +toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInv, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) = + User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, autoAcceptGroupInvitations, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes} where profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_ + autoAcceptGroupInvitations = BoolDef autoAcceptGroupInv toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, Maybe ShortLinkInvitation, LocalAlias, UTCTime, UTCTime) -> PendingContactConnection toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, viaUserContactLink, groupLinkId, customUserProfileId, connReqInv, shortLinkInv, localAlias, createdAt, updatedAt) = diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index e0ee10aff9..746b1e137f 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -62,7 +62,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do cmd = parseChatCommand bs rh' = if either (const False) allowRemoteCommand cmd then rh else Nothing unless (isMessage cmd) $ echo s - r <- execChatCommand rh' bs 0 `runReaderT` cc + r <- execChatCommand (maybe CSLocal CSRemoteHost rh') bs 0 `runReaderT` cc case r of Right r' -> processResp cmd rh r' Left _ -> when (isMessage cmd) $ echo s diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 03f644e641..63a3d5cc70 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -167,7 +167,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} Cha _ -> pure () logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s getRemoteUser rhId = - runReaderT (execChatCommand (Just rhId) "/user" 0) cc >>= \case + runReaderT (execChatCommand (CSRemoteHost rhId) "/user" 0) cc >>= \case Right CRActiveUser {user} -> updateRemoteUser ct user rhId cr -> logError $ "Unexpected reply while getting remote user: " <> tshow cr removeRemoteUser rhId = atomically $ TM.delete rhId (currentRemoteUsers ct) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 7bccd04b71..c0e44277a4 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -143,6 +143,7 @@ data User = User sendRcptsContacts :: Bool, sendRcptsSmallGroups :: Bool, autoAcceptMemberContacts :: Bool, + autoAcceptGroupInvitations :: BoolDef, userMemberProfileUpdatedAt :: Maybe UTCTime, userChatRelay :: BoolDef, clientService :: BoolDef, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 72631dc3f8..985e3c0666 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1917,6 +1917,8 @@ viewSndQueuesInfo = plain . T.intercalate ", " . map showQueueInfo showSwitchStatus = \case SSSendingQKEY -> "switch started" SSSendingQTEST -> "switch secured" + SSSecuringQueue -> "switch confirmed" + SSSendingQEND -> "switch secured" viewContactSwitch :: Contact -> SwitchProgress -> [StyledString] viewContactSwitch _ (SwitchProgress _ SPConfirmed _) = [] diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index ad5fb6c8a4..9c627df90d 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -12,7 +12,7 @@ import ChatTests.Groups (memberJoinChannel, prepareChannel1Relay) import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) -import Control.Monad (forM_, when) +import Control.Monad (forM_, when, void) import qualified Data.Aeson as J import qualified Data.Text as T import Directory.Captcha @@ -42,6 +42,7 @@ directoryServiceTests = do it "admin should delete group registration" testDeleteGroupAdmin it "should change initial member role" testSetRole it "should join found group via link" testJoinGroup + it "should find registered group by link" testSearchByLink it "should support group names with spaces" testGroupNameWithSpaces it "should return more groups in search, all and recent groups" testSearchGroups it "should invite to owners' group if specified" testInviteToOwnersGroup @@ -51,6 +52,7 @@ directoryServiceTests = do it "should de-list if owner is removed from the group" testDelistedOwnerRemoved it "should NOT de-list if another member leaves the group" testNotDelistedMemberLeaves it "should NOT de-list if another member is removed from the group" testNotDelistedMemberRemoved + it "should NOT de-list if the owner rejoins via the group link and leaves the second membership" testNotDelistedOwnerRejoinsViaLink it "should de-list if service is removed from the group" testDelistedServiceRemoved it "should de-list if group is deleted" testDelistedGroupDeleted it "should de-list/re-list when service/owner roles change" testDelistedRoleChanges @@ -61,7 +63,7 @@ directoryServiceTests = do it "the registration owner" testRegOwnerChangedProfile it "another owner" testAnotherOwnerChangedProfile it "another owner not connected to directory" testNotConnectedOwnerChangedProfile - describe "should require profile update if group link is removed by " $ do + describe "should NOT require re-approval if group link is added or removed by" $ do it "the registration owner" testRegOwnerRemovedLink it "another owner" testAnotherOwnerRemovedLink it "another owner not connected to directory" testNotConnectedOwnerRemovedLink @@ -69,7 +71,7 @@ directoryServiceTests = do it "should ask for confirmation if a duplicate group is submitted" testDuplicateAskConfirmation it "should prohibit registration if a duplicate group is listed" testDuplicateProhibitRegistration it "should prohibit confirmation if a duplicate group is listed" testDuplicateProhibitConfirmation - it "should prohibit when profile is updated and not send for approval" testDuplicateProhibitWhenUpdated + it "should allow to rename and approve a duplicate registration" testDuplicateProhibitWhenUpdated it "should prohibit approval if a duplicate group is listed" testDuplicateProhibitApproval describe "list and promote groups" $ do it "should list and promote user's groups" $ testListUserGroups True @@ -169,34 +171,18 @@ testDirectoryService ps = bob <## "invitation to join the group #PSA sent to 'SimpleX Directory'" bob <# "'SimpleX Directory'> You must grant directory service admin role to register the group" bob ##> "/mr PSA 'SimpleX Directory' admin" - -- putStrLn "*** discover service joins group and creates the link for profile" + -- putStrLn "*** discover service joins group and sends the registration for approval" bob <## "#PSA: you changed the role of 'SimpleX Directory' to admin" bob <# "'SimpleX Directory'> Joining the group PSA…" bob <## "#PSA: 'SimpleX Directory' joined the group" - bob <# "'SimpleX Directory'> Joined the group PSA, creating the link…" - bob <# "'SimpleX Directory'> Created the public link to join the group via this directory service that is always online." - bob <## "" - bob <## "Please add it to the group welcome message." - bob <## "For example, add:" - welcomeWithLink <- dropStrPrefix "'SimpleX Directory'> " . dropTime <$> getTermLine bob + bob <# "'SimpleX Directory'> Joined the group PSA. Registration is pending approval — it may take up to 48 hours." bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." bob <## "Captcha verification is enabled. Use /'filter 1' to change it." - -- putStrLn "*** update profile without link" + notifySuperUser_ superUser bob "PSA" "Privacy, Security & Anonymity" Nothing 1 1 + -- putStrLn "*** update profile before approval - new approval code" updateGroupProfile bob "Welcome!" - bob <# "'SimpleX Directory'> The profile updated for ID 1 (PSA), but the group link is not added to the welcome message." - (superUser Thank you! The group link for ID 1 (PSA) is added to the welcome message." - bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - approvalRequested superUser welcomeWithLink (1 :: Int) - -- putStrLn "*** update profile so that it still has link" - let welcomeWithLink' = "Welcome! " <> welcomeWithLink - updateGroupProfile bob welcomeWithLink' - bob <# "'SimpleX Directory'> The group ID 1 (PSA) is updated!" - bob <## "It is hidden from the directory until approved." - superUser <# "'SimpleX Directory'> The group ID 1 (PSA) is updated." - approvalRequested superUser welcomeWithLink' (2 :: Int) + groupUpdatedHidden superUser bob "PSA" "" + notifySuperUser_ superUser bob "PSA" "Privacy, Security & Anonymity" (Just "Welcome!") 1 2 -- putStrLn "*** try approving with the old registration code" bob #> "@'SimpleX Directory' /approve 1:PSA 1" bob <# "'SimpleX Directory'> > /approve 1:PSA 1" @@ -204,44 +190,36 @@ testDirectoryService ps = superUser #> "@'SimpleX Directory' /approve 1:PSA 1" superUser <# "'SimpleX Directory'> > /approve 1:PSA 1" superUser <## " Incorrect approval code" - -- putStrLn "*** update profile so that it has no link" - updateGroupProfile bob "Welcome!" - bob <# "'SimpleX Directory'> The group link for ID 1 (PSA) is removed from the welcome message." - bob <## "" - bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." - superUser <# "'SimpleX Directory'> The group link is removed from ID 1 (PSA), de-listed." - superUser #> "@'SimpleX Directory' /approve 1:PSA 2" - superUser <# "'SimpleX Directory'> > /approve 1:PSA 2" - superUser <## " Error: the group ID 1 (PSA) is not pending approval." - -- putStrLn "*** update profile so that it has link again" - updateGroupProfile bob welcomeWithLink' - bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (PSA) is added to the welcome message." - bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - approvalRequested superUser welcomeWithLink' (1 :: Int) superUser #> "@'SimpleX Directory' /pending" superUser <# "'SimpleX Directory'> > /pending" superUser <## " 1 registered group(s)" superUser <# "'SimpleX Directory'> 1. PSA (Privacy, Security & Anonymity)" superUser <## "Welcome message:" - superUser <##. "Welcome! Link to join the group PSA: " + superUser <## "Welcome!" superUser <## "Owner: bob" superUser <## "2 members" superUser <## "Status: pending admin approval" superUser <## "/'role 1', /'filter 1'" - superUser #> "@'SimpleX Directory' /approve 1:PSA 1" - superUser <# "'SimpleX Directory'> > /approve 1:PSA 1" - superUser <## " Group approved!" - bob <# "'SimpleX Directory'> The group ID 1 (PSA) is approved and listed in directory - please moderate it!" - bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." - bob <## "" - bob <## "Supported commands:" - bob <## "/'filter 1' - to configure anti-spam filter." - bob <## "/'role 1' - to set default member role." - bob <## "/'link 1' - to view/upgrade group link." + welcomeWithLink <- approveRegistration_ superUser bob "PSA" 1 1 2 + -- putStrLn "*** add the link to the welcome message - the group remains listed" + let welcomeWithLink' = "Welcome! " <> welcomeWithLink + updateGroupProfile bob welcomeWithLink' + groupUpdatedListed superUser bob "PSA" "" search bob "privacy" welcomeWithLink' search bob "security" welcomeWithLink' cath `connectVia` dsLink search cath "privacy" welcomeWithLink' + -- putStrLn "*** remove the link from the welcome message - the group remains listed" + updateGroupProfile bob "Welcome!" + groupUpdatedListed superUser bob "PSA" "" + bob #> "@'SimpleX Directory' privacy" + bob <# "'SimpleX Directory'> > privacy" + bob <## " Found 1 group(s)." + bob <# "'SimpleX Directory'> PSA (Privacy, Security & Anonymity)" + bob <## "Welcome message:" + bob <## "Welcome!" + bob <##. "Link to join the group PSA: " + bob <## "2 members" bob #> "@'SimpleX Directory' /exec /contacts" bob <# "'SimpleX Directory'> > /exec /contacts" bob <## " You are not allowed to use this command" @@ -263,15 +241,6 @@ testDirectoryService ps = u ##> ("/set welcome #PSA " <> welcome) u <## "welcome message changed to:" u <## welcome - approvalRequested su welcome grId = do - su <# "'SimpleX Directory'> bob submitted the group ID 1:" - su <## "PSA (Privacy, Security & Anonymity)" - su <## "Welcome message:" - su <## welcome - su <## "2 members" - su <## "" - su <## "To approve send:" - su <# ("'SimpleX Directory'> /approve 1:PSA " <> show grId) testSuspendResume :: HasCallStack => TestParams -> IO () testSuspendResume ps = @@ -297,23 +266,18 @@ testSuspendResume ps = superUser <## " The link to join the group ID 1 (privacy):" superUser <##. "https://localhost/g#" superUser <## "New member role: member" - -- get and change the link to the equivalent - should not ask to re-approve + -- add the link to the welcome message - the group remains listed bob #> "@'SimpleX Directory' /link 1" bob <# "'SimpleX Directory'> > /link 1" bob <## " The link to join the group ID 1 (privacy):" gLink <- getTermLine bob gLink `shouldStartWith` "https://localhost/g#" bob <## "New member role: member" - bob ##> "/show welcome #privacy" - bob <## "Welcome message:" - bob <## ("Link to join the group privacy: " <> gLink) - bob ##> ("/set welcome #privacy Link to join the group privacy: " <> gLink <> "?same_link=true") - bob <## "welcome message changed to:" - bob <## ("Link to join the group privacy: " <> gLink <> "?same_link=true") - bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated!" - bob <## "The group is listed in directory." - superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated - only link or whitespace changes." - superUser <## "The group remained listed in directory." + setWelcomeMessage bob [] ("Link to join the group privacy: " <> gLink) + groupUpdatedListed superUser bob "privacy" "" + -- change the link to the equivalent - should not ask to re-approve + setWelcomeMessage bob [] ("Link to join the group privacy: " <> gLink <> "?same_link=true") + groupUpdatedListed superUser bob "privacy" "" #if !defined(dbPostgres) -- upgrade link -- make it upgradeable first @@ -417,7 +381,6 @@ testSetRole ps = cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) cath <## " Correct, you joined the group privacy" cath <## "#privacy: you joined the group" - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://localhost/g#" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -441,9 +404,8 @@ testJoinGroup ps = cath <# "'SimpleX Directory'> > privacy" cath <## " Found 1 group(s)." cath <# "'SimpleX Directory'> privacy (Privacy)" - cath <## "Welcome message:" - welcomeMsg <- getTermLine cath - let groupLink = dropStrPrefix "Link to join the group privacy: " welcomeMsg + linkLine <- getTermLine cath + let groupLink = dropStrPrefix "Link to join the group privacy: " linkLine cath <## "2 members" cath ##> ("/c " <> groupLink) cath <## "connection request sent!" @@ -459,7 +421,6 @@ testJoinGroup ps = cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) cath <## " Correct, you joined the group privacy" cath <## "#privacy: you joined the group" - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -474,7 +435,6 @@ testJoinGroup ps = do dan <## "#privacy: joining the group..." dan <## "#privacy: you joined the group" - dan <# ("#privacy bob> " <> welcomeMsg) dan <### [ "#privacy: member 'SimpleX Directory' is connected", "#privacy: member cath (Catherine) is connected" @@ -484,6 +444,47 @@ testJoinGroup ps = cath <## "#privacy: new member dan is connected" ] +testSearchByLink :: HasCallStack => TestParams -> IO () +testSearchByLink ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + bob `connectVia` dsLink + submitGroup bob "privacy" "Privacy" + groupAccepted bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 + welcomeWithLink <- approveRegistration superUser bob "privacy" 1 + let link = dropStrPrefix "Link to join the group privacy: " welcomeWithLink + -- user finds the listed group by link + bob #> ("@'SimpleX Directory' " <> link) + bob <# ("'SimpleX Directory'> > " <> link) + bob <## " Found group:" + bob <# "'SimpleX Directory'> privacy (Privacy)" + bob <##. "Link to join the group privacy: " + bob <## "2 members" + -- admin receives the group with status + superUser #> ("@'SimpleX Directory' " <> link) + superUser <# ("'SimpleX Directory'> > " <> link) + superUser <## " 1 registered group(s)" + memberGroupListing superUser bob 1 "privacy" "Privacy" 2 "active" + -- content change hides the group from user search, admin still finds it by link + setWelcomeMessage bob [] "Welcome!" + groupUpdatedHidden superUser bob "privacy" "" + notifySuperUser_ superUser bob "privacy" "Privacy" (Just "Welcome!") 1 1 + bob #> ("@'SimpleX Directory' " <> link) + bob <# ("'SimpleX Directory'> > " <> link) + bob <## " No groups found." + bob <## "To register a group or a channel, please use \"Share via chat\" feature." + superUser #> ("@'SimpleX Directory' " <> link) + superUser <# ("'SimpleX Directory'> > " <> link) + superUser <## " 1 registered group(s)" + superUser <# "'SimpleX Directory'> 1. privacy (Privacy)" + superUser <## "Welcome message:" + superUser <## "Welcome!" + superUser <## "Owner: bob" + superUser <## "2 members" + superUser <## "Status: pending admin approval" + superUser <## "/'role 1', /'filter 1'" + testGroupNameWithSpaces :: HasCallStack => TestParams -> IO () testGroupNameWithSpaces ps = withDirectoryService ps $ \superUser dsLink -> @@ -584,7 +585,6 @@ testSearchGroups ps = receivedGroup :: TestCC -> Int -> Int -> IO () receivedGroup u ix count = do u <#. ("'SimpleX Directory'> " <> groups !! ix) - u <## "Welcome message:" u <##. "Link to join the group " u <## (show count <> " members") @@ -698,6 +698,56 @@ testNotDelistedMemberRemoved ps = cath #> "@'SimpleX Directory_1' privacy" groupFoundN_ "_1" Nothing 2 cath "privacy" +-- Reproduces the de-listing bug where a non-owner member associated with the +-- registration owner's contact (via the probe-and-merge mechanism) de-lists the +-- group when it leaves. The owner joins the directory-managed link a second time +-- (a single client owning both connection ends completes the merge with no +-- modified client), then leaves that second membership while remaining the owner. +testNotDelistedOwnerRejoinsViaLink :: HasCallStack => TestParams -> IO () +testNotDelistedOwnerRejoinsViaLink ps = + withDirectoryService ps $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + bob `connectVia` dsLink + submitGroup bob "privacy" "Privacy" + groupAccepted bob "privacy" 1 + welcomeWithLink <- completeRegistration superUser bob "privacy" "Privacy" 1 + let groupLink = dropStrPrefix "Link to join the group privacy: " welcomeWithLink + -- turn off the captcha filter so the owner's re-join is not screened + bob #> "@'SimpleX Directory' /filter 1 off" + bob <# "'SimpleX Directory'> > /filter 1 off" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: disabled" + bob <## "" + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 captcha' - enable captcha challenge" + bob <## "/'filter 1 name captcha' - enable both" + -- the registration owner connects to the directory-managed link again, + -- creating a second membership that the probe-and-merge mechanism + -- associates with the owner's own contact on the directory service + bob ##> ("/c " <> groupLink) + bob <## "connection request sent!" + bob <## "#privacy_1: joining the group..." + bob <## "#privacy_1: you joined the group" + bob + <### [ "#privacy: 'SimpleX Directory' added bob_1 (Bob) to the group (connecting...)", + "contact and member are merged: 'SimpleX Directory', #privacy_1 'SimpleX Directory_1'", + "use @'SimpleX Directory' to send messages", + "#privacy_1: member bob_2 (Bob) is connected", + "#privacy: new member bob_1 is connected" + ] + -- allow the directory service to complete the contact/member merge that + -- associates the second membership (bob_1) with bob's contact + threadDelay 3000000 + -- owner leaves the second membership, which is not the owner member + bob ##> "/l privacy_1" + bob <## "#privacy_1: you left the group" + bob <## "use /d #privacy_1 to delete the group" + bob <## "#privacy: bob_1 left the group" + -- the group must remain listed: the leaving member is not the owner member + (superUser TestParams -> IO () testDelistedServiceRemoved ps = withDirectoryService ps $ \superUser dsLink -> @@ -814,19 +864,22 @@ testNotSentApprovalBadRoles ps = bob `connectVia` dsLink cath `connectVia` dsLink submitGroup bob "privacy" "Privacy" - welcomeWithLink <- groupAccepted bob "privacy" 1 + groupAccepted bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 bob ##> "/mr privacy 'SimpleX Directory' member" bob <## "#privacy: you changed the role of 'SimpleX Directory' to member" - updateProfileWithLink bob "privacy" welcomeWithLink 1 + bob ##> "/gp privacy privacy Privacy!" + bob <## "description changed to: Privacy!" + groupUpdatedHidden superUser bob "privacy" "" bob <# "'SimpleX Directory'> You must grant directory service admin role to register the group" bob ##> "/mr privacy 'SimpleX Directory' admin" bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin" bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." - notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 + notifySuperUser_ superUser bob "privacy" "Privacy!" Nothing 1 2 groupNotFound cath "privacy" - approveRegistration superUser bob "privacy" 1 + void $ approveRegistration_ superUser bob "privacy" 1 1 2 groupFound cath "privacy" testNotApprovedBadRoles :: HasCallStack => TestParams -> IO () @@ -837,9 +890,8 @@ testNotApprovedBadRoles ps = bob `connectVia` dsLink cath `connectVia` dsLink submitGroup bob "privacy" "Privacy" - welcomeWithLink <- groupAccepted bob "privacy" 1 - updateProfileWithLink bob "privacy" welcomeWithLink 1 - notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 + groupAccepted bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 bob ##> "/mr privacy 'SimpleX Directory' member" bob <## "#privacy: you changed the role of 'SimpleX Directory' to member" let approve = "/approve 1:privacy 1" @@ -852,8 +904,8 @@ testNotApprovedBadRoles ps = bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." - notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 - approveRegistration superUser bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 + void $ approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" testRegOwnerChangedProfile :: HasCallStack => TestParams -> IO () @@ -929,34 +981,21 @@ testRegOwnerRemovedLink ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - bob ##> "/show welcome #privacy" - bob <## "Welcome message:" - welcomeWithLink <- getTermLine bob - bob ##> "/set welcome #privacy Welcome!" - bob <## "welcome message changed to:" - bob <## "Welcome!" - bob <# "'SimpleX Directory'> The group link for ID 1 (privacy) is removed from the welcome message." - bob <## "" - bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." - cath <## "bob updated group #privacy:" - cath <## "welcome message changed to:" - cath <## "Welcome!" - superUser <# "'SimpleX Directory'> The group link is removed from ID 1 (privacy), de-listed." + -- setting the welcome message requires re-approval + setWelcomeMessage bob [cath] "Welcome!" + groupUpdatedHidden superUser bob "privacy" "" + reapproveGroup_ 3 superUser bob (Just "Welcome!") + -- adding the link keeps the group listed + gLink <- getGroupLinkFromBot bob + setWelcomeMessage bob [cath] ("Welcome! Link to join the group privacy: " <> gLink) + groupUpdatedListed superUser bob "privacy" "" + -- removing the link keeps the group listed + setWelcomeMessage bob [cath] "Welcome!" + groupUpdatedListed superUser bob "privacy" "" cath `connectVia` dsLink cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" cath <## "use @'SimpleX Directory' to send messages" - groupNotFound cath "privacy" - let withChangedLink = T.unpack $ T.replace "contact#/?v=2-7&" "contact#/?v=3-7&" $ T.pack welcomeWithLink - bob ##> ("/set welcome #privacy " <> withChangedLink) - bob <## "welcome message changed to:" - bob <## withChangedLink - bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (privacy) is added to the welcome message." - bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - cath <## "bob updated group #privacy:" - cath <## "welcome message changed to:" - cath <## withChangedLink - reapproveGroup 3 superUser bob - groupFoundN 3 cath "privacy" + groupFoundWelcome 3 cath "privacy" "Welcome!" testAnotherOwnerRemovedLink :: HasCallStack => TestParams -> IO () testAnotherOwnerRemovedLink ps = @@ -969,30 +1008,18 @@ testAnotherOwnerRemovedLink ps = cath `connectVia` dsLink cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" cath <## "use @'SimpleX Directory' to send messages" - bob ##> "/show welcome #privacy" - bob <## "Welcome message:" - welcomeWithLink <- getTermLine bob - cath ##> "/set welcome #privacy Welcome!" - cath <## "welcome message changed to:" - cath <## "Welcome!" - bob <## "cath updated group #privacy:" - bob <## "welcome message changed to:" - bob <## "Welcome!" - bob <# "'SimpleX Directory'> The group link for ID 1 (privacy) is removed from the welcome message by cath." - bob <## "" - bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." - superUser <# "'SimpleX Directory'> The group link is removed from ID 1 (privacy), de-listed." - groupNotFound cath "privacy" - cath ##> ("/set welcome #privacy " <> welcomeWithLink) - cath <## "welcome message changed to:" - cath <## welcomeWithLink - bob <## "cath updated group #privacy:" - bob <## "welcome message changed to:" - bob <## welcomeWithLink - bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (privacy) is added to the welcome message by cath." - bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - reapproveGroup 3 superUser bob - groupFoundN 3 cath "privacy" + -- setting the welcome message requires re-approval + setWelcomeMessage cath [bob] "Welcome!" + groupUpdatedHidden superUser bob "privacy" " by cath" + reapproveGroup_ 3 superUser bob (Just "Welcome!") + -- another owner adds the link - the group remains listed + gLink <- getGroupLinkFromBot bob + setWelcomeMessage cath [bob] ("Welcome! Link to join the group privacy: " <> gLink) + groupUpdatedListed superUser bob "privacy" " by cath" + -- another owner removes the link - the group remains listed + setWelcomeMessage cath [bob] "Welcome!" + groupUpdatedListed superUser bob "privacy" " by cath" + groupFoundWelcome 3 cath "privacy" "Welcome!" testNotConnectedOwnerRemovedLink :: HasCallStack => TestParams -> IO () testNotConnectedOwnerRemovedLink ps = @@ -1004,39 +1031,19 @@ testNotConnectedOwnerRemovedLink ps = dan `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - bob ##> "/show welcome #privacy" - bob <## "Welcome message:" - welcomeWithLink <- getTermLine bob - cath ##> "/set welcome #privacy Welcome!" - cath <## "welcome message changed to:" - cath <## "Welcome!" - bob <## "cath updated group #privacy:" - bob <## "welcome message changed to:" - bob <## "Welcome!" - bob <# "'SimpleX Directory'> The group link for ID 1 (privacy) is removed from the welcome message by cath." - bob <## "" - bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." - superUser <# "'SimpleX Directory'> The group link is removed from ID 1 (privacy), de-listed." + -- setting the welcome message requires re-approval + setWelcomeMessage cath [bob] "Welcome!" + groupUpdatedHidden superUser bob "privacy" " by cath" groupNotFound dan "privacy" - cath ##> ("/set welcome #privacy " <> welcomeWithLink) - cath <## "welcome message changed to:" - cath <## welcomeWithLink - bob <## "cath updated group #privacy:" - bob <## "welcome message changed to:" - bob <## welcomeWithLink - -- bob <# "'SimpleX Directory'> The group link is added by another group member, your registration will not be processed." - -- bob <## "" - -- bob <## "Please update the group profile yourself." - -- bob ##> ("/set welcome #privacy " <> welcomeWithLink <> " - welcome!") - -- bob <## "welcome message changed to:" - -- bob <## (welcomeWithLink <> " - welcome!") - bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (privacy) is added to the welcome message by cath." - bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - -- cath <## "bob updated group #privacy:" - -- cath <## "welcome message changed to:" - -- cath <## (welcomeWithLink <> " - welcome!") - reapproveGroup 3 superUser bob - groupFoundN 3 dan "privacy" + reapproveGroup_ 3 superUser bob (Just "Welcome!") + -- the not connected owner adds the link - the group remains listed + gLink <- getGroupLinkFromBot bob + setWelcomeMessage cath [bob] ("Welcome! Link to join the group privacy: " <> gLink) + groupUpdatedListed superUser bob "privacy" " by cath" + -- the not connected owner removes the link - the group remains listed + setWelcomeMessage cath [bob] "Welcome!" + groupUpdatedListed superUser bob "privacy" " by cath" + groupFoundWelcome 3 dan "privacy" "Welcome!" testDuplicateAskConfirmation :: HasCallStack => TestParams -> IO () testDuplicateAskConfirmation ps = @@ -1045,16 +1052,17 @@ testDuplicateAskConfirmation ps = withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" - _ <- groupAccepted bob "privacy" 1 + groupAccepted bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 cath `connectVia` dsLink submitGroup cath "privacy" "Privacy" cath <# "'SimpleX Directory'> The group privacy (Privacy) is already submitted to the directory." cath <## "To confirm the registration, please send:" cath <# "'SimpleX Directory'> /confirm 1:privacy" cath #> "@'SimpleX Directory' /confirm 1:privacy" - welcomeWithLink <- groupAccepted cath "privacy" 1 + groupAccepted cath "privacy" 1 groupNotFound bob "privacy" - completeRegistrationId superUser cath "privacy" "Privacy" welcomeWithLink 2 1 + void $ completeRegistrationId superUser cath "privacy" "Privacy" 2 1 groupFound bob "privacy" testDuplicateProhibitRegistration :: HasCallStack => TestParams -> IO () @@ -1076,14 +1084,14 @@ testDuplicateProhibitConfirmation ps = withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" - welcomeWithLink <- groupAccepted bob "privacy" 1 + groupAccepted bob "privacy" 1 cath `connectVia` dsLink submitGroup cath "privacy" "Privacy" cath <# "'SimpleX Directory'> The group privacy (Privacy) is already submitted to the directory." cath <## "To confirm the registration, please send:" cath <# "'SimpleX Directory'> /confirm 1:privacy" groupNotFound cath "privacy" - completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 + void $ completeRegistration superUser bob "privacy" "Privacy" 1 groupFound cath "privacy" cath #> "@'SimpleX Directory' /confirm 1:privacy" cath <# "'SimpleX Directory'> The group privacy (Privacy) is already listed in the directory, please choose another name." @@ -1095,27 +1103,27 @@ testDuplicateProhibitWhenUpdated ps = withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" - welcomeWithLink <- groupAccepted bob "privacy" 1 + groupAccepted bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 cath `connectVia` dsLink submitGroup cath "privacy" "Privacy" cath <# "'SimpleX Directory'> The group privacy (Privacy) is already submitted to the directory." cath <## "To confirm the registration, please send:" cath <# "'SimpleX Directory'> /confirm 1:privacy" cath #> "@'SimpleX Directory' /confirm 1:privacy" - welcomeWithLink' <- groupAccepted cath "privacy" 1 + groupAccepted cath "privacy" 1 + notifySuperUser superUser cath "privacy" "Privacy" 2 groupNotFound cath "privacy" - completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 + void $ approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" - cath ##> ("/set welcome privacy " <> welcomeWithLink') - cath <## "welcome message changed to:" - cath <## welcomeWithLink' - cath <# "'SimpleX Directory'> The group privacy (Privacy) is already listed in the directory, please choose another name." + -- the duplicate registration is renamed and approved cath ##> "/gp privacy security Security" cath <## "changed to #security (Security)" - cath <# "'SimpleX Directory'> Thank you! The group link for ID 1 (security) is added to the welcome message." - cath <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - notifySuperUser superUser cath "security" "Security" welcomeWithLink' 2 - approveRegistrationId superUser cath "security" 2 1 + cath <# "'SimpleX Directory'> The group ID 1 (security) is updated!" + cath <## "It is hidden from the directory until approved." + superUser <# "'SimpleX Directory'> The group ID 2 (security) is updated." + notifySuperUser_ superUser cath "security" "Security" Nothing 2 2 + void $ approveRegistration_ superUser cath "security" 2 1 2 groupFound bob "security" groupFound cath "security" @@ -1126,18 +1134,18 @@ testDuplicateProhibitApproval ps = withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" - welcomeWithLink <- groupAccepted bob "privacy" 1 + groupAccepted bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 cath `connectVia` dsLink submitGroup cath "privacy" "Privacy" cath <# "'SimpleX Directory'> The group privacy (Privacy) is already submitted to the directory." cath <## "To confirm the registration, please send:" cath <# "'SimpleX Directory'> /confirm 1:privacy" cath #> "@'SimpleX Directory' /confirm 1:privacy" - welcomeWithLink' <- groupAccepted cath "privacy" 1 - updateProfileWithLink cath "privacy" welcomeWithLink' 1 - notifySuperUser superUser cath "privacy" "Privacy" welcomeWithLink' 2 + groupAccepted cath "privacy" 1 + notifySuperUser superUser cath "privacy" "Privacy" 2 groupNotFound cath "privacy" - completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 + void $ approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" -- fails at approval, as already listed let approve = "/approve 2:privacy 1" @@ -1183,15 +1191,11 @@ testListUserGroups promote ps = checkListings ["privacy", "security"] ["privacy"] bob ##> "/gp privacy privacy" bob <## "description removed" - bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated!" - bob <## "It is hidden from the directory until approved." cath <## "bob updated group #privacy:" cath <## "description removed" - superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated." + groupUpdatedHidden superUser bob "privacy" "" superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" superUser <## "privacy" - superUser <## "Welcome message:" - superUser <##. "Link to join the group privacy: https://localhost/g#" superUser <## "3 members" superUser <## "" superUser <## "To approve send:" @@ -1200,13 +1204,7 @@ testListUserGroups promote ps = superUser #> "@'SimpleX Directory' /approve 1:privacy 1" superUser <# "'SimpleX Directory'> > /approve 1:privacy 1" superUser <## " Group approved (promoted)!" - bob <# "'SimpleX Directory'> The group ID 1 (privacy) is approved and listed in directory - please moderate it!" - bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." - bob <## "" - bob <## "Supported commands:" - bob <## "/'filter 1' - to configure anti-spam filter." - bob <## "/'role 1' - to set default member role." - bob <## "/'link 1' - to view/upgrade group link." + void $ groupApprovedNotification bob "privacy" 1 checkListings ["privacy", "security"] ["privacy"] checkListings :: HasCallStack => [T.Text] -> [T.Text] -> IO () @@ -1256,7 +1254,6 @@ testAlwaysCaptcha ps = cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) cath <## " Correct, you joined the group privacy" cath <## "#privacy: you joined the group" - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1311,7 +1308,6 @@ testCaptchaByDefault ps = cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) cath <## " Correct, you joined the group privacy" cath <## "#privacy: you joined the group" - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1338,7 +1334,6 @@ testCapthaScreening ps = cath <## " Incorrect text, please try again." captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath sendCaptcha cath captcha - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1362,7 +1357,6 @@ testCapthaScreening ps = -- message from cath that left pastMember <- dropStrPrefix "#privacy: 'SimpleX Directory' forwarded a message from an unknown member, creating unknown member record " <$> getTermLine cath cath <# ("#privacy " <> pastMember <> "> hello [>>]") - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath_1 (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath_1 is connected" @@ -1440,7 +1434,6 @@ testVoiceCaptchaScreening ps@TestParams {tmpPath} = do cath <## " Audio captcha is already enabled." -- send correct captcha sendCaptcha cath captcha - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1550,7 +1543,6 @@ testVoiceCaptchaVoiceDisabled ps@TestParams {tmpPath} = do cath <#. "#privacy (support) 'SimpleX Directory'> sends file " cath <##. "use /fr 1" sendCaptcha cath captcha - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1607,7 +1599,6 @@ testVoiceCaptchaOldClient ps@TestParams {tmpPath} = do cath <## " Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." -- text captcha still works sendCaptcha cath captcha - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1707,8 +1698,6 @@ memberGroupListing su owner = groupListing_ su (Just owner) groupListing_ :: HasCallStack => TestCC -> Maybe TestCC -> Int -> String -> String -> Int -> String -> IO () groupListing_ su owner_ gId n fn count status = do su <# ("'SimpleX Directory'> " <> show gId <> ". " <> n <> " (" <> fn <> ")") - su <## "Welcome message:" - su <##. ("Link to join the group " <> n <> ": ") forM_ owner_ $ \owner -> do ownerName <- userName owner su <## ("Owner: " <> ownerName) @@ -1717,11 +1706,15 @@ groupListing_ su owner_ gId n fn count status = do su <## ("/'role " <> show gId <> "', /'filter " <> show gId <> "'") reapproveGroup :: HasCallStack => Int -> TestCC -> TestCC -> IO () -reapproveGroup count superUser bob = do +reapproveGroup count superUser bob = reapproveGroup_ count superUser bob Nothing + +reapproveGroup_ :: HasCallStack => Int -> TestCC -> TestCC -> Maybe String -> IO () +reapproveGroup_ count superUser bob welcome_ = do superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" superUser <##. "privacy (" - superUser <## "Welcome message:" - superUser <##. "Link to join the group privacy: " + forM_ welcome_ $ \welcome -> do + superUser <## "Welcome message:" + superUser <## welcome superUser <## (show count <> " members") superUser <## "" superUser <## "To approve send:" @@ -1729,13 +1722,7 @@ reapproveGroup count superUser bob = do superUser #> "@'SimpleX Directory' /approve 1:privacy 1" superUser <# "'SimpleX Directory'> > /approve 1:privacy 1" superUser <## " Group approved!" - bob <# "'SimpleX Directory'> The group ID 1 (privacy) is approved and listed in directory - please moderate it!" - bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." - bob <## "" - bob <## "Supported commands:" - bob <## "/'filter 1' - to configure anti-spam filter." - bob <## "/'role 1' - to set default member role." - bob <## "/'link 1' - to view/upgrade group link." + void $ groupApprovedNotification bob "privacy" 1 addCathAsOwner :: HasCallStack => TestCC -> TestCC -> IO () addCathAsOwner bob cath = do @@ -1808,8 +1795,8 @@ registerGroup su u n fn = registerGroupId su u n fn 1 1 registerGroupId :: TestCC -> TestCC -> String -> String -> Int -> Int -> IO () registerGroupId su u n fn gId ugId = do submitGroup u n fn - welcomeWithLink <- groupAccepted u n ugId - completeRegistrationId su u n fn welcomeWithLink gId ugId + groupAccepted u n ugId + void $ completeRegistrationId su u n fn gId ugId submitGroup :: TestCC -> String -> String -> IO () submitGroup u n fn = do @@ -1819,70 +1806,91 @@ submitGroup u n fn = do u ##> ("/a " <> viewName n <> " 'SimpleX Directory' admin") u <## ("invitation to join the group #" <> viewName n <> " sent to 'SimpleX Directory'") -groupAccepted :: TestCC -> String -> Int -> IO String +groupAccepted :: TestCC -> String -> Int -> IO () groupAccepted u n ugId = do u <### [ WithTime ("'SimpleX Directory'> Joining the group " <> n <> "…"), ConsoleString ("#" <> viewName n <> ": 'SimpleX Directory' joined the group") ] - u <# ("'SimpleX Directory'> Joined the group " <> n <> ", creating the link…") - u <# "'SimpleX Directory'> Created the public link to join the group via this directory service that is always online." - u <## "" - u <## "Please add it to the group welcome message." - u <## "For example, add:" - welcomeWithLink <- dropStrPrefix "'SimpleX Directory'> " . dropTime <$> getTermLine u + u <# ("'SimpleX Directory'> Joined the group " <> n <> ". Registration is pending approval — it may take up to 48 hours.") u <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." u <## ("Captcha verification is enabled. Use /'filter " <> show ugId <> "' to change it.") - pure welcomeWithLink -completeRegistration :: TestCC -> TestCC -> String -> String -> String -> Int -> IO () -completeRegistration su u n fn welcomeWithLink gId = - completeRegistrationId su u n fn welcomeWithLink gId gId +completeRegistration :: TestCC -> TestCC -> String -> String -> Int -> IO String +completeRegistration su u n fn gId = + completeRegistrationId su u n fn gId gId -completeRegistrationId :: TestCC -> TestCC -> String -> String -> String -> Int -> Int -> IO () -completeRegistrationId su u n fn welcomeWithLink gId ugId = do - updateProfileWithLink u n welcomeWithLink ugId - notifySuperUser su u n fn welcomeWithLink gId +completeRegistrationId :: TestCC -> TestCC -> String -> String -> Int -> Int -> IO String +completeRegistrationId su u n fn gId ugId = do + notifySuperUser su u n fn gId approveRegistrationId su u n gId ugId -updateProfileWithLink :: TestCC -> String -> String -> Int -> IO () -updateProfileWithLink u n welcomeWithLink ugId = do - u ##> ("/set welcome " <> viewName n <> " " <> welcomeWithLink) - u <## "welcome message changed to:" - u <## welcomeWithLink - u <# ("'SimpleX Directory'> Thank you! The group link for ID " <> show ugId <> " (" <> n <> ") is added to the welcome message.") - u <## "You will be notified once the group is added to the directory - it may take up to 48 hours." +notifySuperUser :: TestCC -> TestCC -> String -> String -> Int -> IO () +notifySuperUser su u n fn gId = notifySuperUser_ su u n fn Nothing gId 1 -notifySuperUser :: TestCC -> TestCC -> String -> String -> String -> Int -> IO () -notifySuperUser su u n fn welcomeWithLink gId = do +notifySuperUser_ :: TestCC -> TestCC -> String -> String -> Maybe String -> Int -> Int -> IO () +notifySuperUser_ su u n fn welcome_ gId gaId = do uName <- userName u su <# ("'SimpleX Directory'> " <> uName <> " submitted the group ID " <> show gId <> ":") su <## (n <> if null fn then "" else " (" <> fn <> ")") - su <## "Welcome message:" - su <## welcomeWithLink + forM_ welcome_ $ \welcome -> do + su <## "Welcome message:" + su <## welcome su .<## "members" su <## "" su <## "To approve send:" - let approve = "/approve " <> show gId <> ":" <> viewName n <> " 1" + let approve = "/approve " <> show gId <> ":" <> viewName n <> " " <> show gaId su <# ("'SimpleX Directory'> " <> approve) -approveRegistration :: TestCC -> TestCC -> String -> Int -> IO () +approveRegistration :: TestCC -> TestCC -> String -> Int -> IO String approveRegistration su u n gId = approveRegistrationId su u n gId gId -approveRegistrationId :: TestCC -> TestCC -> String -> Int -> Int -> IO () -approveRegistrationId su u n gId ugId = do - let approve = "/approve " <> show gId <> ":" <> viewName n <> " 1" +approveRegistrationId :: TestCC -> TestCC -> String -> Int -> Int -> IO String +approveRegistrationId su u n gId ugId = approveRegistration_ su u n gId ugId 1 + +approveRegistration_ :: TestCC -> TestCC -> String -> Int -> Int -> Int -> IO String +approveRegistration_ su u n gId ugId gaId = do + let approve = "/approve " <> show gId <> ":" <> viewName n <> " " <> show gaId su #> ("@'SimpleX Directory' " <> approve) su <# ("'SimpleX Directory'> > " <> approve) su <## " Group approved!" + groupApprovedNotification u n ugId + +groupApprovedNotification :: TestCC -> String -> Int -> IO String +groupApprovedNotification u n ugId = do u <# ("'SimpleX Directory'> The group ID " <> show ugId <> " (" <> n <> ") is approved and listed in directory - please moderate it!") - u <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." + u <## "To help people join, copy the next message with the group link and add it to the end of the group welcome message. The group will remain listed. Any other change to the group profile hides it from the directory until it is re-approved." u <## "" u <## "Supported commands:" u <## ("/'filter " <> show ugId <> "' - to configure anti-spam filter.") u <## ("/'role " <> show ugId <> "' - to set default member role.") - u <## ("/'link " <> show ugId <> "' - to view/upgrade group link.") + u <## ("/'link " <> show ugId <> "' - to view group link.") + dropStrPrefix "'SimpleX Directory'> " . dropTime <$> getTermLine u + +groupUpdatedHidden :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () +groupUpdatedHidden superUser u n byMember = do + u <# ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> "!") + u <## "It is hidden from the directory until approved." + superUser <# ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> ".") + +groupUpdatedListed :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () +groupUpdatedListed superUser u n byMember = do + u <# ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> "!") + u <## "The group is listed in directory." + superUser <# ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> " - only link or whitespace changes.") + superUser <## "The group remained listed in directory." + +setWelcomeMessage :: HasCallStack => TestCC -> [TestCC] -> String -> IO () +setWelcomeMessage u others welcome = do + uName <- userName u + u ##> ("/set welcome #privacy " <> welcome) + u <## "welcome message changed to:" + u <## welcome + forM_ others $ \m -> do + m <## (uName <> " updated group #privacy:") + m <## "welcome message changed to:" + m <## welcome connectVia :: TestCC -> String -> IO () u `connectVia` dsLink = do @@ -1901,10 +1909,8 @@ joinGroup :: String -> TestCC -> TestCC -> IO () joinGroup gName member host = do let gn = "#" <> gName memberName <- userName member - hostName <- userName host member ##> ("/j " <> gName) member <## (gn <> ": you joined the group") - member <#. (gn <> " " <> hostName <> "> Link to join the group " <> gName <> ": ") host <## (gn <> ": " <> memberName <> " joined the group") leaveGroup :: String -> TestCC -> IO () @@ -1940,10 +1946,29 @@ groupFoundN_ suffix shownId_ count u name = do u <# ("'SimpleX Directory" <> suffix <> "'> > " <> name) u <## " Found 1 group(s)." u <#. ("'SimpleX Directory" <> suffix <> "'> " <> maybe "" (\gId -> show gId <> ". ") shownId_ <> name) - u <## "Welcome message:" u <##. "Link to join the group " u <## (show count <> " members") +groupFoundWelcome :: HasCallStack => Int -> TestCC -> String -> String -> IO () +groupFoundWelcome count u name welcome = do + u #> ("@'SimpleX Directory' " <> name) + u <# ("'SimpleX Directory'> > " <> name) + u <## " Found 1 group(s)." + u <#. ("'SimpleX Directory'> " <> name) + u <## "Welcome message:" + u <## welcome + u <##. "Link to join the group " + u <## (show count <> " members") + +getGroupLinkFromBot :: HasCallStack => TestCC -> IO String +getGroupLinkFromBot u = do + u #> "@'SimpleX Directory' /link 1" + u <# "'SimpleX Directory'> > /link 1" + u <## " The link to join the group ID 1 (privacy):" + gLink <- getTermLine u + u <## "New member role: member" + pure gLink + groupNotFound :: TestCC -> String -> IO () groupNotFound = groupNotFound_ "" @@ -2029,7 +2054,7 @@ testHelpNoAudio ps = bob <## "/list - list the groups you registered." bob <## "`/role ` - view and set default member role for your group." bob <## "`/filter ` - view and set spam filter settings for group." - bob <## "`/link ` - view and upgrade group link." + bob <## "`/link ` - view group link." bob <## "`/delete :` - remove the group you submitted from directory, with ID and name as shown by /list command." bob <## "" bob <## "To search for groups, send the search text." diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 76e808d816..dc6b6b5431 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -50,7 +50,7 @@ import Simplex.FileTransfer.Server.Store import Simplex.FileTransfer.Transport (alpnSupportedXFTPhandshakes, supportedFileServerVRange) import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite -import Simplex.Messaging.Agent.Protocol (duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) +import Simplex.Messaging.Agent.Protocol (supportedSMPAgentVRange) import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Store.Entity (SDBStored (..)) import Simplex.Messaging.Agent.Store.Interface (closeDBStore) @@ -58,8 +58,6 @@ import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConf import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) -import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange) -import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (ProtocolType (..)) import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM (ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration) @@ -234,7 +232,7 @@ testAgentCfgVPrev = testAgentCfg { smpClientVRange = prevRange $ smpClientVRange testAgentCfg, smpAgentVRange = prevRange supportedSMPAgentVRange, - e2eEncryptVRange = prevRange supportedE2EEncryptVRange, + -- e2eEncryptVRange = prevRange supportedE2EEncryptVRange, smpCfg = (smpCfg testAgentCfg) {serverVRange = prevRange $ serverVRange $ smpCfg testAgentCfg} } @@ -242,8 +240,8 @@ testAgentCfgV1 :: AgentConfig testAgentCfgV1 = testAgentCfg { smpClientVRange = v1Range, - smpAgentVRange = mkVersionRange duplexHandshakeSMPAgentVersion pqdrSMPAgentVersion, - e2eEncryptVRange = mkVersionRange CR.kdfX3DHE2EEncryptVersion CR.pqRatchetE2EEncryptVersion, + smpAgentVRange = versionToRange (Version 6), + e2eEncryptVRange = versionToRange(Version 3), smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange minClientSMPRelayVersion} } diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index c65306474c..ce62b300e4 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -52,6 +52,9 @@ chatProfileTests = do it "reject profile image that is too large" testSetProfileImageTooLarge it "set profile image from file" testSetProfileImageFromFile it "use multiword profile names" testMultiWordProfileNames + it "auto-accept group invitations" testAutoAcceptGroupInvitations + it "auto-accept group invitations on a second profile" testAutoAcceptGroupInvitationsSecondProfile + it "auto-accept group invitations on an inactive profile" testAutoAcceptGroupInvitationsInactiveProfile it "present supporter badge to contacts" testUserBadgeBroadcast it "supporter badge sent to contact connecting after attach" testUserBadgeOnConnect it "supporter badge sent to member joining via group link" testUserBadgeGroupLink @@ -539,6 +542,61 @@ testSetProfileImageFromFile ps = testChat aliceProfile test ps alice ##> ("/set profile image file " <> emptyPath) alice <##. "bad chat command: image file is empty" +testAutoAcceptGroupInvitations :: HasCallStack => TestParams -> IO () +testAutoAcceptGroupInvitations = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + bob ##> "/set accept group invitations on" + bob <## "ok" + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/a team bob admin" + alice <## "invitation to join the group #team sent to bob" + concurrently_ + (alice <## "#team: bob joined the group") + (bob <## "#team: you joined the group") + +testAutoAcceptGroupInvitationsSecondProfile :: HasCallStack => TestParams -> IO () +testAutoAcceptGroupInvitationsSecondProfile = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + bob ##> "/create user bob2" + showActiveUser bob "bob2" + bob ##> "/set accept group invitations on" + bob <## "ok" + connectUsers alice bob + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/a team bob2 admin" + alice <## "invitation to join the group #team sent to bob2" + concurrently_ + (alice <## "#team: bob2 joined the group") + (bob <## "#team: you joined the group") + +testAutoAcceptGroupInvitationsInactiveProfile :: HasCallStack => TestParams -> IO () +testAutoAcceptGroupInvitationsInactiveProfile = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + bob ##> "/create user bob2" + showActiveUser bob "bob2" + bob ##> "/set accept group invitations on" + bob <## "ok" + connectUsers alice bob + -- switch away: bob2 now has auto-accept on but is NOT the active profile + bob ##> "/user bob" + showActiveUser bob "bob (Bob)" + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/a team bob2 admin" + alice <## "invitation to join the group #team sent to bob2" + concurrently_ + (alice <## "#team: bob2 joined the group") + (bob <## "[user: bob2] #team: you joined the group") + testMultiWordProfileNames :: HasCallStack => TestParams -> IO () testMultiWordProfileNames = testChat3 aliceProfile' bobProfile' cathProfile' $ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index dfcf76f761..f16b3ac090 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -124,9 +124,9 @@ skip = before_ . pendingWith versionTestMatrix2 :: (HasCallStack => Bool -> Bool -> TestCC -> TestCC -> IO ()) -> SpecWith TestParams versionTestMatrix2 runTest = do it "current" $ testChat2 aliceProfile bobProfile (runTest True True) - it "prev" $ runTestCfg2 testCfgVPrev testCfgVPrev (runTest False True) - it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev (runTest False True) - it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg (runTest False True) + it "prev" $ runTestCfg2 testCfgVPrev testCfgVPrev (runTest True True) + it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev (runTest True True) + it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg (runTest True True) it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile (runTest True False) it "old to curr" $ runTestCfg2 testCfg testCfgV1 (runTest True True) it "curr to old" $ runTestCfg2 testCfgV1 testCfg (runTest True False) diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index 37fab0e4f0..bbdf8c14b0 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -17,10 +17,10 @@ activeUserExistsTagged :: LB.ByteString activeUserExistsTagged = "{\"error\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}" activeUserSwift :: LB.ByteString -activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false,\"clientService\":false}}}}" +activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false,\"clientService\":false}}}}" activeUserTagged :: LB.ByteString -activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false,\"clientService\":false}}}" +activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false,\"clientService\":false}}}" chatStartedSwift :: LB.ByteString chatStartedSwift = "{\"result\":{\"_owsf\":true,\"chatStarted\":{}}}" @@ -35,7 +35,7 @@ connectionsDiffTagged :: LB.ByteString connectionsDiffTagged = "{\"result\":{\"type\":\"connectionsDiff\",\"userIds\":{\"missingIds\":[],\"extraIds\":[]},\"connIds\":{\"missingIds\":[],\"extraIds\":[]}}}" userJSON :: LB.ByteString -userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false}" +userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false}" parsedMarkdownSwift :: LB.ByteString parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" diff --git a/tests/MessageBatching.hs b/tests/MessageBatching.hs index 00cbbd757b..01515fb6a3 100644 --- a/tests/MessageBatching.hs +++ b/tests/MessageBatching.hs @@ -33,6 +33,7 @@ import Simplex.Chat.Protocol GrpMsgForward (GrpMsgForward), MsgContent (MCText), VerifiedMsg (VMUnsigned), + maxBatchElementCount, maxEncodedMsgLength, mcSimple, ) @@ -45,6 +46,7 @@ batchingTests = describe "message batching tests" $ do testBatchingCorrectness testBinaryBatchingCorrectness it "image x.msg.new and x.msg.file.descr should fit into single batch" testImageFitsSingleBatch + it "splits a batch that exceeds the element count limit" testBatchElementCountLimit it "does not create a relay delivery body when every task is oversized" testRelayBatchAllLarge it "classifies a task that fits raw but not as a framed singleton as large" testRelayBatchSingletonOverflow @@ -150,6 +152,13 @@ testImageFitsSingleBatch = do runBatcherTest' BMJson maxEncodedMsgLength [msg xMsgNewStr, msg descrStr] [] [batched] +-- elements are far below maxEncodedMsgLength, so only the element count guard can split this +testBatchElementCountLimit :: IO () +testBatchElementCountLimit = + runBatcherTest' BMJson maxEncodedMsgLength (replicate (maxBatchElementCount + 1) "a") [] ["a", batched] + where + batched = "[" <> B.intercalate "," (replicate maxBatchElementCount "a") <> "]" + testRelayBatchAllLarge :: IO () testRelayBatchAllLarge = do let task1 = deliveryTask 1 "one" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 63dbea549f..f5b55fbddd 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -9,6 +9,9 @@ module ProtocolTests where import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.List (isInfixOf) +import qualified Data.List.NonEmpty as L import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Simplex.Chat.Library.Internal (decodeLinkUserData, encodeShortLinkData) import Simplex.Chat.Protocol @@ -16,8 +19,10 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Compression (compress1) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet +import Simplex.Messaging.Encoding (smpEncode) import Simplex.Messaging.Protocol (EntityId (..), supportedSMPClientVRange) import Simplex.Messaging.ServiceScheme import Simplex.Messaging.Version @@ -27,6 +32,22 @@ protocolTests :: Spec protocolTests = do decodeChatMessageTest shortLinkDataTests + batchLimitTests + +batchLimitTests :: Spec +batchLimitTests = describe "Chat message batch limits" $ do + it "parses a JSON batch at the element count limit" $ + length (parseChatMessages $ jsonBatch maxBatchElementCount) `shouldBe` maxBatchElementCount + it "rejects a JSON batch above the element count limit" $ + batchError (jsonBatch $ maxBatchElementCount + 1) `shouldSatisfy` isInfixOf "too many messages in batch" + it "rejects compressed blocks that together exceed the element count limit" $ + batchError (compressedBatch 2 maxBatchElementCount) `shouldSatisfy` isInfixOf "too many messages in batch" + where + jsonBatch n = "[" <> B.intercalate "," (replicate n "{}") <> "]" + compressedBatch k n = markCompressedBatch . smpEncode . L.fromList $ replicate k (compress1 $ jsonBatch n) + batchError s = case parseChatMessages s of + [Left e] -> e + rs -> "expected a single error, got " <> show (length rs) <> " results" srv :: SMPServer srv = SMPServer "smp.simplex.im" "5223" (C.KeyHash "\215m\248\251") @@ -220,7 +241,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted it "x.file" $ - "{\"v\":\"9\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"9\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing} it "x.file without file invitation" $ "{\"v\":\"9\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" @@ -229,7 +250,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.file.acpt.inv" $ - "{\"v\":\"9\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"9\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg" it "x.file.acpt.inv" $ "{\"v\":\"9\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" @@ -256,10 +277,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.contact\",\"params\":{\"msgId\":\"AQIDBA==\",\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing Nothing (Just (SharedMsgId "\1\2\3\4", MCText {text = "hello"})) it "x.grp.inv" $ - "{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Nothing, groupSize = Nothing} it "x.grp.inv with group link id" $ - "{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + "{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} it "x.grp.acpt without incognito profile" $ "{\"v\":\"9\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" @@ -280,16 +301,16 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} (Just MemberRestrictions {restriction = MRSBlocked}) it "x.grp.mem.inv" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.inv w/t directConnReq" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.fwd" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" @@ -310,10 +331,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel it "x.grp.direct.inv" $ - "{\"v\":\"9\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"9\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XGrpDirectInv testConnReq (Just $ MCText "hello") Nothing it "x.grp.direct.inv without content" $ - "{\"v\":\"9\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"9\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XGrpDirectInv testConnReq Nothing Nothing -- it "x.grp.msg.forward" -- $ "{\"v\":\"9\",\"event\":\"x.grp.msg.forward\",\"params\":{\"msgForward\":{\"memberId\":\"AQIDBA==\",\"msg\":\"{\"v\":\"9\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}\",\"msgTs\":\"1970-01-01T00:00:01.000000001Z\"}}}" diff --git a/website/src/crowdfunding-news.html b/website/src/crowdfunding-news.html new file mode 100644 index 0000000000..49fc0403d0 --- /dev/null +++ b/website/src/crowdfunding-news.html @@ -0,0 +1,16 @@ +--- +layout: layouts/main.html +title: "SimpleX Crowdfunding News" +description: "The news about SimpleX Chat equity crowdfunding on Wefunder." +templateEngineOverride: njk +--- + +
+ diff --git a/website/src/index.html b/website/src/index.html index 3ce15f6d56..c4fef9c8b8 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -100,7 +100,7 @@ active_home: true

{{ "index-hero-h1" | i18n({}, lang) | safe }}

{{ "index-hero-h2" | i18n({}, lang) | safe }}

{{ "index-hero-p1" | i18n({}, lang) | safe }}

-

{{ "index-hero-invest" | i18n({}, lang) | safe }} {{ "index-hero-invest-cta" | i18n({}, lang) | safe }}

+

{{ "index-hero-invest" | i18n({}, lang) | safe }} {{ "index-hero-invest-cta" | i18n({}, lang) | safe }}