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/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index a87b9b46f4..51746766bd 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -859,8 +859,8 @@ enum ConnectTarget { func strConnectTarget(_ str: String) -> ConnectTarget? { let parsedMd = parseSimpleXMarkdown(str) let links = parsedMd?.filter { $0.format?.isSimplexLink ?? false } ?? [] - return if links.count == 1, case let .simplexLink(_, linkType, _, smpHosts) = links[0].format { - .link(text: links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts)) + return if links.count == 1, case let .simplexLink(showText, linkType, simplexUri, smpHosts) = links[0].format { + .link(text: showText != nil ? simplexUri : links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts)) } else if links.isEmpty, let nameFt = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } }), case let .simplexName(nameInfo) = nameFt.format { diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index b7753e8539..4495fd8cc2 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -8,6 +8,7 @@ // Spec: spec/client/navigation.md import SwiftUI +import StoreKit import SimpleXChat private struct VersionDescription { @@ -41,6 +42,11 @@ private struct FeatureView { let view: () -> any View } +let isInUS = { + let code = SKStorefront().countryCode + return code == "USA" || code == "" +}() + private let versionDescriptions: [VersionDescription] = [ VersionDescription( version: "v4.2", @@ -665,9 +671,15 @@ private let versionDescriptions: [VersionDescription] = [ ] ), VersionDescription( - version: "v7.0", + version: isInUS ? "v7.0.1" : "v7.0", post: nil, - features: [ + features: (isInUS ? [ + .view(FeatureView( + icon: nil, + title: "You can now invest in SimpleX Chat", + view: { InvestInSimpleXChat() } + )) + ] : []) + [ .feature(Description( icon: "at", title: "SimpleX public names (BETA)", @@ -762,6 +774,134 @@ fileprivate struct CreateUpdateAddressShortLink: View { } } +fileprivate struct InvestInSimpleXChat: View { + @EnvironmentObject var theme: AppTheme + @State private var showGetStakeSheet = false + + var body: some View { + 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() + .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 + } +} + private enum WhatsNewViewSheet: Identifiable { case showConditions 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.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 0f7dab1eba..3b0c6ed57d 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -183,8 +183,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -563,8 +563,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -735,8 +735,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -822,8 +822,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.2-DOL5oxHswUnIfh2IFo9Jhf.a */, ); path = Libraries; sourceTree = ""; @@ -2081,7 +2081,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2106,7 +2106,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2131,7 +2131,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2156,7 +2156,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2173,11 +2173,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2193,11 +2193,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2218,7 +2218,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2233,7 +2233,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2255,7 +2255,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2270,7 +2270,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2292,7 +2292,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2318,7 +2318,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2343,7 +2343,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2370,7 +2370,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2397,7 +2397,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2412,7 +2412,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2431,7 +2431,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 346; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2446,7 +2446,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/multiplatform/README.md b/apps/multiplatform/README.md index eef1048ada..54e17d4c4e 100644 --- a/apps/multiplatform/README.md +++ b/apps/multiplatform/README.md @@ -9,11 +9,19 @@ This is the **Kotlin Multiplatform (KMP)** mobile and desktop client for SimpleX ## Build Commands ```bash -# Android debug APK -./gradlew assembleDebug +# Android debug APK, assembleGoogleDebug builds the flavor with the Play Billing dependency +./gradlew assembleFossDebug -# Android release APK -./gradlew assembleRelease +# Android release APK, distributed via F-Droid and GitHub +./gradlew assembleFossRelease + +# Android app bundle, distributed via Google Play, includes Play Billing +./gradlew bundleGoogleRelease + +# Always name the flavor for releases. The aggregate tasks (build, assemble, assembleRelease, +# bundle, bundleRelease) fail on purpose: they would package a release APK with Play Billing, +# or an app bundle without it. +# The fdroiddata recipe defaults to assembleRelease and must be changed to assembleFossRelease. # Desktop distribution (current OS) ./gradlew :desktop:packageDistributionForCurrentOS @@ -22,7 +30,7 @@ This is the **Kotlin Multiplatform (KMP)** mobile and desktop client for SimpleX ./gradlew desktopTest # Run Android instrumented tests (requires connected device/emulator) -./gradlew connectedAndroidTest +./gradlew connectedFossDebugAndroidTest # Build native libraries for all platforms ./gradlew common:cmakeBuild -PcrossCompile diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 5255319194..419fef90b9 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -35,6 +35,21 @@ android { manifestPlaceholders["extract_native_libs"] = rootProject.extra["compression.level"] as Int != 0 } + // `google` is distributed via Google Play as an app bundle and includes Play Billing. + // `foss` is distributed via F-Droid and as APKs on GitHub, without Play dependencies. + flavorDimensions += "store" + productFlavors { + create("google") { + dimension = "store" + buildConfigField("boolean", "PLAY_STORE", "true") + } + create("foss") { + dimension = "store" + isDefault = true + buildConfigField("boolean", "PLAY_STORE", "false") + } + } + buildTypes { debug { applicationIdSuffix = rootProject.extra["application_id.suffix"] as String @@ -128,8 +143,28 @@ android { } } +// The graph is checked rather than the requested task, because every aggregate task +// (assemble, assembleRelease, build, bundle, ...) packages these variants too. +val projectPath = project.path +val apkTasks = setOf("packageFossDebug", "packageGoogleDebug", "packageFossRelease", "packageGoogleRelease") +val apkTaskPaths = apkTasks.map { "$projectPath:$it" }.toSet() +val bundleTaskPaths = apkTaskPaths.map { it + "Bundle" }.toSet() +gradle.taskGraph.whenReady { + if (hasTask("$projectPath:packageGoogleRelease")) { + throw GradleException("A release apk must not include Play Billing, use assembleFossRelease or bundleGoogleRelease") + } + if (hasTask("$projectPath:packageFossReleaseBundle")) { + throw GradleException("An app bundle must include Play Billing, use bundleGoogleRelease or assembleFossRelease") + } + // `isBundle` above is derived from the whole invocation, so a bundle in it disables abi splits + if (apkTaskPaths.any { hasTask(it) } && bundleTaskPaths.any { hasTask(it) }) { + throw GradleException("Build the apks and the bundle in separate invocations, the bundle disables abi splits") + } +} + dependencies { implementation(project(":common")) + "googleImplementation"("com.android.billingclient:billing:9.1.0") implementation("androidx.core:core-ktx:1.13.1") //implementation("androidx.compose.ui:ui:${rootProject.extra["compose.version"] as String}") //implementation("androidx.compose.material:material:$compose_version") @@ -160,58 +195,61 @@ dependencies { tasks { val compressApk by creating { doLast { - val isRelease = gradle.startParameter.taskNames.find { it.lowercase().contains("release") } != null - val buildType: String = if (isRelease) "release" else "debug" val javaHome = System.getProperties()["java.home"] ?: org.gradle.internal.jvm.Jvm.current().javaHome val sdkDir = android.sdkDirectory.absolutePath - val keyAlias: String - val keyPassword: String - val storeFile: String - val storePassword: String - if (project.properties["android.injected.signing.key.alias"] != null) { - keyAlias = project.properties["android.injected.signing.key.alias"] as String - keyPassword = project.properties["android.injected.signing.key.password"] as String - storeFile = project.properties["android.injected.signing.store.file"] as String - storePassword = project.properties["android.injected.signing.store.password"] as String - } else { - try { - val gradleConfig = android.signingConfigs.getByName(buildType) - keyAlias = gradleConfig.keyAlias!! - keyPassword = gradleConfig.keyPassword!! - storeFile = gradleConfig.storeFile!!.absolutePath - storePassword = gradleConfig.storePassword!! - } catch (e: UnknownDomainObjectException) { - // There is no signing config for current build type, can"t sign the apk - println("No signing configs for this build type: $buildType") - return@doLast + // A single invocation can package more than one variant, for example assembleDebug + gradle.taskGraph.allTasks.filter { it.path in apkTaskPaths }.forEach { packageTask -> + val variant = packageTask.name.removePrefix("package") + val buildType: String = if (variant.endsWith("Release")) "release" else "debug" + val keyAlias: String + val keyPassword: String + val storeFile: String + val storePassword: String + if (project.properties["android.injected.signing.key.alias"] != null) { + keyAlias = project.properties["android.injected.signing.key.alias"] as String + keyPassword = project.properties["android.injected.signing.key.password"] as String + storeFile = project.properties["android.injected.signing.store.file"] as String + storePassword = project.properties["android.injected.signing.store.password"] as String + } else { + try { + val gradleConfig = android.signingConfigs.getByName(buildType) + keyAlias = gradleConfig.keyAlias!! + keyPassword = gradleConfig.keyPassword!! + storeFile = gradleConfig.storeFile!!.absolutePath + storePassword = gradleConfig.storePassword!! + } catch (e: UnknownDomainObjectException) { + // There is no signing config for current build type, can"t sign the apk + println("No signing configs for this build type: $buildType") + return@forEach + } + } + val outputDir = packageTask.outputs.files.files.last() + exec { + workingDir("../../scripts/android") + environment = mapOf( + "JAVA_HOME" to "$javaHome", + "PATH" to "${System.getenv("PATH")}:$javaHome/bin" + ) + commandLine = listOf( + "./compress-and-sign-apk.sh", + "${rootProject.extra["compression.level"]}", + "$outputDir", + sdkDir, + storeFile, + storePassword, + keyAlias, + keyPassword + ) } - } - lateinit var outputDir: File - named(if (isRelease) "packageRelease" else "packageDebug") { - outputDir = outputs.files.files.last() - } - exec { - workingDir("../../scripts/android") - environment = mapOf( - "JAVA_HOME" to "$javaHome", - "PATH" to "${System.getenv("PATH")}:$javaHome/bin" - ) - commandLine = listOf( - "./compress-and-sign-apk.sh", - "${rootProject.extra["compression.level"]}", - "$outputDir", - sdkDir, - storeFile, - storePassword, - keyAlias, - keyPassword - ) - } - if (project.properties["android.injected.signing.key.alias"] != null && buildType == "release") { - File(outputDir, "android-release.apk").renameTo(File(outputDir, "simplex.apk")) - File(outputDir, "android-armeabi-v7a-release.apk").renameTo(File(outputDir, "simplex-armv7a.apk")) - File(outputDir, "android-arm64-v8a-release.apk").renameTo(File(outputDir, "simplex.apk")) + if (project.properties["android.injected.signing.key.alias"] != null && buildType == "release") { + val flavor = variant.removeSuffix("Release").lowercase() + mapOf("arm64-v8a" to "simplex.apk", "armeabi-v7a" to "simplex-armv7a.apk").forEach { (abi, name) -> + if (!File(outputDir, "android-$flavor-$abi-release.apk").renameTo(File(outputDir, name))) { + logger.warn("No $abi apk to rename to $name") + } + } + } } // View all gradle properties set // project.properties.each { k, v -> println "$k -> $v" } @@ -221,9 +259,7 @@ tasks { // Don"t do anything if no compression is needed if (rootProject.extra["compression.level"] as Int != 0) { whenTaskAdded { - if (name == "packageDebug") { - finalizedBy(compressApk) - } else if (name == "packageRelease") { + if (name in apkTasks) { finalizedBy(compressApk) } } diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt new file mode 100644 index 0000000000..181fe42389 --- /dev/null +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -0,0 +1,4 @@ +package chat.simplex.app + +// Play Billing is only in the google flavor, so the Play country stays unknown here +fun loadPlayStoreCountry() {} diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt new file mode 100644 index 0000000000..a0e7734ff0 --- /dev/null +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -0,0 +1,31 @@ +package chat.simplex.app + +import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.platform.androidPlayStoreCountry +import com.android.billingclient.api.* + +// Requests the country of the Google Play account into [androidPlayStoreCountry]. +// It stays null when Play is unavailable or the user is not signed in. +fun loadPlayStoreCountry() { + val client = BillingClient.newBuilder(androidAppContext) + .setListener { _, _ -> } + .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) + .build() + client.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) { + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + client.endConnection() + return + } + client.getBillingConfigAsync(GetBillingConfigParams.newBuilder().build()) { configResult, config -> + if (configResult.responseCode == BillingClient.BillingResponseCode.OK) { + androidPlayStoreCountry.value = config?.countryCode + } + client.endConnection() + } + } + + // The connection is only used for this one request, it is not retried + override fun onBillingServiceDisconnected() = client.endConnection() + }) +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 83767f90d7..ce47d2c5de 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -341,6 +341,8 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidIsXiaomiDevice(): Boolean = setOf("xiaomi", "redmi", "poco").contains(Build.BRAND.lowercase()) + override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry() + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { @@ -370,6 +372,8 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidCreateActiveCallState(): Closeable = ActiveCallState() override val androidApiLevel: Int get() = Build.VERSION.SDK_INT + + override val androidIsPlayStoreBuild: Boolean get() = BuildConfig.PLAY_STORE } } diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 98845365fc..1f55b9c660 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -189,7 +189,6 @@ buildConfig { buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"") buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"") - buildConfigField("Boolean", "ANDROID_BUNDLE", "${extra["android.bundle"]}") buildConfigField("Boolean", "SIMPLEX_ASSETS", "$hasSimplexAssets") } } 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 c98f8f9f89..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 @@ -341,6 +342,19 @@ actual suspend fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolea VideoPlayerInterface.PreviewAndDuration(null, 0, 0) } +actual suspend fun hasVideoTrack(uri: URI): Boolean { + val mmr = MediaMetadataRetriever() + return try { + mmr.setDataSource(androidAppContext, uri.toUri()) + mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO) == "yes" + } catch (e: Exception) { + Log.e(TAG, "Utils.android hasVideoTrack error: ${e.message}") + false + } finally { + mmr.release() + } +} + actual fun ByteArray.toBase64StringForPassphrase(): String = Base64.encodeToString(this, Base64.DEFAULT) actual fun String.toByteArrayFromBase64ForPassphrase(): ByteArray = Base64.decode(this, Base64.DEFAULT) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index 7a96bd99d2..140c1951ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -1,5 +1,7 @@ package chat.simplex.common.platform +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.ui.theme.DefaultTheme @@ -30,6 +32,9 @@ else val databaseBackend: String = if (appPlatform == AppPlatform.ANDROID) "sqlite" else BuildConfigCommon.DATABASE_BACKEND +// Country of the Google Play account, only set in the google flavor of the Android app +val androidPlayStoreCountry: MutableState = mutableStateOf(null) + class FifoQueue(private var capacity: Int) : LinkedList() { override fun add(element: E): Boolean { if (size > capacity) removeFirstOrNull() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 448100bc17..b46123c9cf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -29,7 +29,11 @@ interface PlatformInterface { fun androidRestartNetworkObserver() {} fun androidCreateActiveCallState(): Closeable = Closeable { } fun androidIsXiaomiDevice(): Boolean = false + // Requests the Google Play account country into [androidPlayStoreCountry] + fun androidLoadPlayStoreCountry() {} val androidApiLevel: Int? get() = null + // The build distributed via Google Play, which has to follow its policies + val androidIsPlayStoreBuild: Boolean get() = false @Composable fun androidLockPortraitOrientation() {} suspend fun androidAskToAllowBackgroundCalls(): Boolean = true @Composable fun desktopShowAppUpdateNotice() {} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index ff393a3c30..6bfbad52ef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -285,7 +285,22 @@ expect fun AttachmentSelection( ) fun MutableState.onFilesAttached(uris: List) { - val groups = uris.groupBy { isImage(it) || isVideoUri(it) } + // The extension is enough to classify every format except .webm, which is just as commonly an + // audio-only container as a video one. An audio-only file has no frame to embed and is sent as a file, + // but that can only be told from the content, so reading it is deferred to a background thread. + // Only done here, where files arrive without the user saying how to send them (drag & drop, paste) - + // an explicitly picked video is still sent as one. + if (uris.none { isWebmUri(it) }) { + attachFiles(uris, emptySet()) + } else { + CoroutineScope(Dispatchers.IO).launch { + attachFiles(uris, uris.filter { isWebmUri(it) && hasVideoTrack(it) }.toSet()) + } + } +} + +private fun MutableState.attachFiles(uris: List, webmVideos: Set) { + val groups = uris.groupBy { isImage(it) || (isVideoUri(it) && (!isWebmUri(it) || it in webmVideos)) } val media = groups[true] ?: emptyList() val files = groups[false] ?: emptyList() if (media.isNotEmpty()) { @@ -298,9 +313,12 @@ fun MutableState.onFilesAttached(uris: List) { private fun isVideoUri(uri: URI): Boolean { val name = getFileName(uri)?.lowercase() ?: return false return name.endsWith(".mov") || name.endsWith(".avi") || name.endsWith(".mp4") || - name.endsWith(".mpg") || name.endsWith(".mpeg") || name.endsWith(".mkv") + name.endsWith(".mpg") || name.endsWith(".mpeg") || name.endsWith(".mkv") || + name.endsWith(".webm") } +private fun isWebmUri(uri: URI): Boolean = getFileName(uri)?.lowercase()?.endsWith(".webm") == true + fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { val maxFileSize = value.maxFileSize diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 77b4c40d7d..68fa25d553 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -183,6 +183,8 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow WhatsNewView(close = close, updatedConditions = showUpdatedConditions) } } 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/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 70f4a1759b..3128c63234 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -495,6 +495,9 @@ fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration +// Whether the file really contains a video track. Reads container metadata only, without decoding a frame. +expect suspend fun hasVideoTrack(uri: URI): Boolean + fun showWrongUriAlert() { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.non_content_uri_alert_title), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index d3bca178aa..f3006d221b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -836,7 +836,8 @@ fun strConnectTarget(str: String): ConnectTarget? { val links = parsedMd.filter { it.format?.isSimplexLink ?: false } if (links.size == 1) { val fmt = links[0].format as Format.SimplexLink - return ConnectTarget.Link(links[0].text, fmt.linkType, fmt.simplexLinkText) + val text = if (fmt.showText != null) fmt.simplexUri else links[0].text + return ConnectTarget.Link(text, fmt.linkType, fmt.simplexLinkText) } if (links.isEmpty()) { val nameFt = parsedMd.firstOrNull { it.format is Format.SimplexName } 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 f95ffc1961..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,17 +12,41 @@ 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 import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel import chat.simplex.common.model.* @@ -34,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) { @@ -913,9 +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 = { modalManager -> InvestInSimpleXChatView(modalManager) } + ), VersionFeature.FeatureDescription( icon = MR.images.ic_alternate_email, titleId = MR.strings.v7_0_simplex_names, @@ -950,6 +985,295 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean { return v != lastVersion } +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) { val clipboard = LocalClipboardManager.current 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 c8e040c592..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 @@ -22,7 +22,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* -import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* @@ -30,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 @@ -111,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() } } @@ -143,7 +155,7 @@ fun HelpAndSupportView( SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_support_project)) { - if (!BuildConfigCommon.ANDROID_BUNDLE) { + if (!platform.androidIsPlayStoreBuild) { ContributeItem(uriHandler) } if (appPlatform.isAndroid) { 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 7950e7cc1c..739467b3ff 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2736,6 +2736,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/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/commonMain/resources/assets/default/MR/images/own_stake.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index c3b6dc3a4c..768d2f421d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -7,6 +7,10 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface +import uk.co.caprica.vlcj.media.Media +import uk.co.caprica.vlcj.media.MediaEventAdapter +import uk.co.caprica.vlcj.media.MediaParsedStatus +import uk.co.caprica.vlcj.media.ParseFlag import uk.co.caprica.vlcj.media.VideoOrientation import uk.co.caprica.vlcj.player.base.* import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent @@ -255,6 +259,43 @@ actual class VideoPlayer actual constructor( return@withContext VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration) } + // Parsing a local container header takes a few dozen ms, this is only a guard against a stuck parse + private const val PARSE_TIMEOUT_MS = 3000L + + // Reads container metadata to tell whether there is a video track at all, without decoding a frame. + // libvlc signals the end of parsing with an event, so no polling or frame-decoding budget is needed. + suspend fun hasVideoTrack(uri: URI): Boolean = withContext(previewThread.asCoroutineDispatcher()) { + if (!uri.toFile().exists()) return@withContext false + val media = try { + vlcPreviewFactory.media().newMedia(uri.toFile().absolutePath) + } catch (e: Exception) { + Log.e(TAG, "hasVideoTrack unable to create media: ${e.stackTraceToString()}") + null + } ?: return@withContext false + try { + val parsed = CompletableDeferred() + media.events().addMediaEventListener(object: MediaEventAdapter() { + // vlcj maps an unknown status int to null, and a null here would throw on its event thread + override fun mediaParsedChanged(parsedMedia: Media?, newStatus: MediaParsedStatus?) { + parsed.complete(newStatus) + } + }) + if (!media.parsing().parse(PARSE_TIMEOUT_MS.toInt(), ParseFlag.PARSE_LOCAL)) { + return@withContext false + } + if (withTimeoutOrNull(PARSE_TIMEOUT_MS) { parsed.await() } != MediaParsedStatus.DONE) { + media.parsing().stop() + return@withContext false + } + media.info().videoTracks().isNotEmpty() + } catch (e: Exception) { + Log.e(TAG, "hasVideoTrack error: ${e.stackTraceToString()}") + false + } finally { + media.release() + } + } + val playerThread = Executors.newSingleThreadExecutor() private val previewThread = Executors.newSingleThreadExecutor() private val playersPool: ArrayList = ArrayList() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt index e9924914ef..3293d4f5bd 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt @@ -9,5 +9,6 @@ fun isVideo(uri: URI): Boolean { path.endsWith(".mp4") || path.endsWith(".mpg") || path.endsWith(".mpeg") || - path.endsWith(".mkv") + path.endsWith(".mkv") || + path.endsWith(".webm") } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 3ccb915661..d4c42790d2 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -255,6 +255,8 @@ actual suspend fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolea return VideoPlayer.getBitmapFromVideo(null, uri, withAlertOnException) } +actual suspend fun hasVideoTrack(uri: URI): Boolean = VideoPlayer.hasVideoTrack(uri) + @OptIn(ExperimentalEncodingApi::class) actual fun ByteArray.toBase64StringForPassphrase(): String = Base64.encode(this) diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index b4a7b4319a..93c83de951 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=7.0 -android.version_code=366 +android.version_name=7.1-beta.0 +android.version_code=369 -android.bundle=false - -desktop.version_name=7.0 -desktop.version_code=155 +desktop.version_name=7.1-beta.0 +desktop.version_code=156 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/multiplatform/spec/architecture.md b/apps/multiplatform/spec/architecture.md index cfef4d06c2..9911a2670f 100644 --- a/apps/multiplatform/spec/architecture.md +++ b/apps/multiplatform/spec/architecture.md @@ -370,6 +370,8 @@ var platform: PlatformInterface = object : PlatformInterface {} | `androidCreateActiveCallState()` | empty `Closeable` | Create `ActiveCallState` | | `androidIsXiaomiDevice()` | `false` | Check device brand | | `androidApiLevel` | `null` | `Build.VERSION.SDK_INT` | +| `androidIsPlayStoreBuild` | `false` | `BuildConfig.PLAY_STORE` | +| `androidLoadPlayStoreCountry()` | no-op | Request the Play account country (google flavor only) | | `androidLockPortraitOrientation()` | no-op | Lock to `SCREEN_ORIENTATION_PORTRAIT` | | `androidAskToAllowBackgroundCalls()` | `true` | Show battery restriction dialog | | `desktopShowAppUpdateNotice()` | no-op | Show update notice (Desktop only) | 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/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..81cb35954b 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) @@ -242,7 +238,7 @@ directoryCommands = "Group settings" [ CBCCommand "role" "View new member role" idParam, CBCCommand "filter" "Anti-spam filter" idParam, - CBCCommand "link" "View and upgrade group link" idParam, + CBCCommand "link" "View group link" idParam, CBCCommand "delete" "Remove a group from directory" (Just ":''") ] ] @@ -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 @@ -492,33 +488,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 +510,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 +549,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 @@ -1094,11 +1033,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 +1045,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 +1102,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 +1114,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 +1158,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 +1166,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 +1200,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 +1230,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 +1291,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 +1330,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 +1358,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 +1368,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 +1455,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 +1551,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/assets/multiplatform/resources/MR/images/own_stake@2x.png b/assets/multiplatform/resources/MR/images/own_stake@2x.png new file mode 100644 index 0000000000..fadf1b9599 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/own_stake@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/own_stake@3x.png b/assets/multiplatform/resources/MR/images/own_stake@3x.png new file mode 100644 index 0000000000..106ee804ca Binary files /dev/null and b/assets/multiplatform/resources/MR/images/own_stake@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/own_stake_light@2x.png b/assets/multiplatform/resources/MR/images/own_stake_light@2x.png new file mode 100644 index 0000000000..8f02fa3eaa Binary files /dev/null and b/assets/multiplatform/resources/MR/images/own_stake_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/own_stake_light@3x.png b/assets/multiplatform/resources/MR/images/own_stake_light@3x.png new file mode 100644 index 0000000000..0a994849d6 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/own_stake_light@3x.png differ diff --git a/cabal.project b/cabal.project index 04dcea61a3..0895113b5e 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: d5441f514f1ae838a78de93628bb8fa1cae8f786 + tag: c5ff829cfb37247a09d82000d42c82280015b4ba 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/contributing/PROJECT.md b/docs/contributing/PROJECT.md index 3f7e6e0e54..40417a6539 100644 --- a/docs/contributing/PROJECT.md +++ b/docs/contributing/PROJECT.md @@ -68,14 +68,15 @@ The project uses several custom forks managed via `cabal.project`: ```bash cd apps/multiplatform -# Build Android debug APK -./gradlew assembleDebug +# Build Android debug APK; `foss` ships to F-Droid/GitHub, `google` adds Play Billing. +# The aggregate tasks fail by design, see apps/multiplatform/README.md +./gradlew assembleFossDebug # Build desktop ./gradlew :desktop:packageDistributionForCurrentOS # Run Android tests -./gradlew connectedAndroidTest +./gradlew connectedFossDebugAndroidTest ``` ### iOS 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/docs/rfcs/2026-08-05-markdown-hyperlink-connect.md b/docs/rfcs/2026-08-05-markdown-hyperlink-connect.md new file mode 100644 index 0000000000..2fe40755e8 --- /dev/null +++ b/docs/rfcs/2026-08-05-markdown-hyperlink-connect.md @@ -0,0 +1,33 @@ +# Connecting via a SimpleX link written as a markdown hyperlink + +## Problem + +Pasting a short SimpleX link written as a markdown hyperlink — `[label](https://smp6.simplex.im/a#...)` — into the chat list search, the new chat sheet search, or "Tap to paste link" fails with "Invalid connection link" instead of connecting. + +## Cause + +`markdownP` parses such a link into a single fragment whose `format` is `SimplexLink` but whose `text` is the whole markdown source: + +``` +[{"format":{"type":"simplexLink","showText":"label","linkType":"contact", + "simplexUri":"simplex:/a#...?h=smp6.simplex.im","smpHosts":["smp6.simplex.im"]}, + "text":"[label](https://smp6.simplex.im/a#...)"}] +``` + +`strConnectTarget` returns that `text` as the string to connect with. For a bare link `text` is the link, so it works; for a hyperlink it is `[label](link)`, which the core rejects as `InvalidConnReq`. + +## Design + +Use `simplexUri` — the link the parser already resolved — when the fragment came from the hyperlink parser, and keep using `text` otherwise: + +``` +text = if showText != null then simplexUri else text +``` + +`showText` is an exact discriminator, not a heuristic: `simplexUriFormat` is called with `Just t` only from `sowLinkP` (the hyperlink parser) and with `Nothing` from `wordMD` (bare link). Gating on it leaves every bare-link path unchanged. + +This also matches how the chat item renderer already resolves the same format — `TextItemView.kt` takes `simplexUri`, never the fragment `text`, when `showText` is set. `strConnectTarget` was the outlier. + +## Scope + +Short links only. `sowLinkP` rejects a full link inside a hyperlink (`fail "full SimpleX link in hyperlink"`), so `[label](full-link)` yields no formatting at all and never reaches this code — it stays treated as search text, as before. Bare full links are unaffected. diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 01d88ab770..5dd0bd0417 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.10.3", + "version": "0.11.0", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 76833f70c8..7409468212 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.0.0", + "version": "7.1.0-beta.0", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.10.3", + "@simplex-chat/types": "^0.11.0", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-nodejs/src/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index e0685e0123..c5d2e5f774 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v7.0.0'; +const RELEASE_TAG = 'v7.1.0-beta.0'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { 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/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index e1cbccc2da..abb6fed421 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ Bump both together for normal releases. For wrapper-only fixes use a PEP 440 post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. """ -__version__ = "7.0.0" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.0.0" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.1.0b0" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.1.0-beta.0" # simplex-chat-libs release tag (no 'v' prefix) 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/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-07-webm-video-detection.md b/plans/2026-08-07-webm-video-detection.md new file mode 100644 index 0000000000..e8d4ff1e08 --- /dev/null +++ b/plans/2026-08-07-webm-video-detection.md @@ -0,0 +1,19 @@ +# Send dropped `.webm` as video only when it has a video track + +## Problem + +Dragging a `.webm` file onto the desktop compose area attaches it as a plain file instead of embedding it as a video with a preview frame and duration. Every other video container the app recognises (`.mov`, `.avi`, `.mp4`, `.mpg`, `.mpeg`, `.mkv`) embeds. The same omission hides `.webm` from the "Attach → video" file picker, so the only way to send one is "Choose file", which sends it as a document. + +## Cause + +`isVideoUri` (`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt:298`) classifies attachments by file extension and does not list `.webm`; the desktop picker filter `isVideo` (`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt:5`) repeats the same list with the same omission. `onFilesAttached` groups the dropped URIs by `isImage(it) || isVideoUri(it)`, so a `.webm` fails both predicates, falls into the files group and reaches `processPickedFile`, which builds a `ComposePreview.FilePreview`. + +Adding the extension to both lists is not sufficient on its own. Unlike the other containers, `.webm` is used about as often for audio alone as for video — it is `MediaRecorder`'s default audio container, and Opus/Vorbis in WebM is widespread on the web. An audio-only file classified as video reaches the video branch of `processPickedMedia`, where `getBitmapFromVideo` finds no video track, returns a null preview and raises the "video decoding" alert; the item is then skipped and nothing is attached at all (`ComposeView.kt:366-376`). That is strictly worse than the file attachment the same drop produced before. + +## Fix + +Add `.webm` to both extension lists, and for `.webm` alone decide from the file's content rather than its name. A new `expect suspend fun hasVideoTrack(uri)` (`views/helpers/Utils.kt`) reports whether the container declares a video track, reading metadata only and never decoding a frame. On desktop it is implemented with libvlc's media parse (`platform/VideoPlayer.desktop.kt`), which signals completion with an event rather than a poll, so no frame-decoding budget is needed; measured at 12-346 ms across VP8, VP9, AV1, alpha and a 42 MB file, with a 3 s timeout as a guard against a stuck parse. On Android it uses `MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO`. Either implementation answering "no", or failing, attaches the file as a file, which is always safe. + +`onFilesAttached` consults it only when a `.webm` is actually among the dropped URIs; every other attachment keeps the original synchronous code path on the caller thread, so the change adds no latency and no threading difference to images, documents or the other video containers. Files with a video track are sent as video, the rest as files. + +The content check is applied only where the user has not said how the file should be sent — drag & drop and paste. An explicitly picked video is still trusted: selecting an audio-only `.webm` through "Attach → video" raises the existing decoding error, which matches how the other containers already behave. diff --git a/scripts/android/build-android-bundle.sh b/scripts/android/build-android-bundle.sh index b784da2aad..972fb0ee72 100755 --- a/scripts/android/build-android-bundle.sh +++ b/scripts/android/build-android-bundle.sh @@ -23,5 +23,8 @@ unzip -o "$tmp/libsimplex.zip" -d "$tmp/simplex-chat/apps/multiplatform/common/s curl -sSf "$libsup" -o "$tmp/libsupport.zip" unzip -o "$tmp/libsupport.zip" -d "$tmp/simplex-chat/apps/multiplatform/common/src/commonMain/cpp/android/libs/arm64-v8a" -gradle -p "$tmp/simplex-chat/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean build -cp "$tmp/simplex-chat/apps/multiplatform/android/build/outputs/apk/release/android-release-unsigned.apk" "$PWD/simplex-chat.apk" +# Build only the arch the libs were downloaded for +sed -i.bak 's/include(.*/include("arm64-v8a")/' "$tmp/simplex-chat/apps/multiplatform/android/build.gradle.kts" + +gradle -p "$tmp/simplex-chat/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleFossRelease +cp "$tmp/simplex-chat/apps/multiplatform/android/build/outputs/apk/foss/release/android-foss-arm64-v8a-release-unsigned.apk" "$PWD/simplex-chat.apk" diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 7edee9c304..267db9f243 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -101,7 +101,7 @@ build() { sed -i.bak 's/${extract_native_libs}/true/' "$folder/apps/multiplatform/android/src/main/AndroidManifest.xml" sed -i.bak 's/jniLibs.useLegacyPackaging =.*/jniLibs.useLegacyPackaging = true/' "$folder/apps/multiplatform/android/build.gradle.kts" sed -i.bak '/android {/a lint {abortOnError = false}' "$folder/apps/multiplatform/android/build.gradle.kts" - sed -i.bak '/tasks/Q' "$folder/apps/multiplatform/android/build.gradle.kts" + sed -i.bak '/^tasks {/Q' "$folder/apps/multiplatform/android/build.gradle.kts" sed -i.bak "s/android.version_code=.*/android.version_code=${vercode}/" "$folder/apps/multiplatform/gradle.properties" for arch in $arches; do @@ -119,7 +119,7 @@ build() { arch_map "$arch" android_tmp_folder="${tmp}/android-${arch}" - android_apk_output="${folder}/apps/multiplatform/android/build/outputs/apk/release/android-${android_arch}-release-unsigned.apk" + android_apk_output="${folder}/apps/multiplatform/android/build/outputs/apk/foss/release/android-foss-${android_arch}-release-unsigned.apk" android_apk_output_final="simplex-chat-${android_arch}.apk" libs_folder="${folder}/apps/multiplatform/common/src/commonMain/cpp/android/libs" @@ -134,7 +134,7 @@ build() { # Build only one arch sed -i.bak "s/include(.*/include(\"${android_arch}\")/" "$folder/apps/multiplatform/android/build.gradle.kts" - gradle -p "$folder/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleRelease + gradle -p "$folder/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleFossRelease mkdir -p "$android_tmp_folder" unzip -oqd "$android_tmp_folder" "$android_apk_output" diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 2a39a6baf4..1b15e68d60 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e3d53428a0c5776f9682264a56436ce97bc3eff8" = "1i3x4q6sc8w6hndrmmrsgc15di0bz6w29r4y5cr985rvs9c2mx7d"; + "https://github.com/simplex-chat/simplexmq.git"."c5ff829cfb37247a09d82000d42c82280015b4ba" = "1sjv0bkyqqimi2s84s4jsdbh5gwnw2pv5bkl1i3cgp6081wyfx31"; "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/scripts/simplex-chat-reproduce-builds-android.sh b/scripts/simplex-chat-reproduce-builds-android.sh index f8bb3224cc..4bd7262d17 100755 --- a/scripts/simplex-chat-reproduce-builds-android.sh +++ b/scripts/simplex-chat-reproduce-builds-android.sh @@ -118,7 +118,7 @@ check_apk() { verify_apk() { apk_name="$1" - # Release APKs are packaged by AGP (gradle :android:assembleRelease; AGP version is + # Release APKs are packaged by AGP (gradle :android:assembleFossRelease; AGP version is # gradle.plugin.version in apps/multiplatform/gradle.properties), which zero-pads ZIP # alignment. Do NOT add --pad-like-apksigner (standalone apksigner >= 35.0.0-rc1 uses # the 0xd935 extra-field padding) unless AGP is bumped to a packager that uses it — diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 51f2ac8e91..404456b122 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.0 +version: 7.1.0.2 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat 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..b9ce3587fc 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 } @@ -695,6 +696,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 920331ac98..8216840bbd 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 @@ -3604,7 +3609,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_ diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 653871db74..16e9dda48b 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3711,7 +3711,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/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index ce6a8c4c9f..0613069dd7 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -135,7 +135,6 @@ 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 @@ -148,6 +147,9 @@ createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {di :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, 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 (?,?,?,?,?)" @@ -338,12 +340,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/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 6884bf7e04..f4dcb5c5a7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -7931,6 +7931,10 @@ 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=?) 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/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index ad5fb6c8a4..4fe275a0dd 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_, void, when) 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 @@ -61,7 +62,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 +70,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 +170,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 +189,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 +240,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 +265,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 +380,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 +403,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 +420,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 +434,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 +443,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 +584,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") @@ -814,19 +813,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 +839,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 +853,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 +930,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 +957,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 +980,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 +1001,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 +1033,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 +1052,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 +1083,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 +1140,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 +1153,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 +1203,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 +1257,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 +1283,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 +1306,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 +1383,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 +1492,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 +1548,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 +1647,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 +1655,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 +1671,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 +1744,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 +1755,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 +1858,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 +1895,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 +2003,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/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..e241ddf15b 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") 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 }}