diff options
| author | pepper <3782201+rohvani@users.noreply.github.com> | 2026-08-11 21:02:47 -0400 |
|---|---|---|
| committer | pepper <3782201+rohvani@users.noreply.github.com> | 2026-08-13 12:17:40 -0400 |
| commit | 473d6c2558c72a8dbbcf591e387c080baf2f531a (patch) | |
| tree | 4ec29f5fc170aa92098ca5c5c74dc53e97604342 /indra/newview | |
| parent | f6fa702706aeba8c5a3760a98883943cba3c973b (diff) | |
Implement ChatService persistent history stitching
Synchronize eligible direct IM conversations through the ChatService
capabilities into durable per-resident CSV archives. Validate deterministic
conversation IDs, UUIDv1 ordering, complete discovery and history payloads,
and the account-wide deletion cutoff before a bounded pass can append or
rebuild an archive.
Run discovery, pacing, retries, metadata resolution, and participant paging
through one account-scoped manager. Immediately prioritize new Direct
sessions, P2P previews, and accepted inbound IMs while honoring transcript
consent, authoritative mute state, capability changes, and fail-closed
account state.
Stitch ordinary and monthly legacy plaintext before the oldest accumulated
service row, keep normal plaintext logging active, and merge the first
validated service page in memory while older backfill continues. Drive
Direct and Preview reloads and their nonmodal loading status from shared
resident snapshots.
Maintain the human-readable chat_service_index.csv manifest and protect
archive updates with strict validation, torn-tail repair, corrupt-file
preservation, private file stamps, and atomic replacement. Extend Delete
Transcripts with a durable TimeUUID boundary and an asynchronous sweep that
suppresses every historical source until cleanup completes safely.
Add core coverage for deterministic IDs, UUIDv1 and timestamp validation,
strict page and list contracts, RFC 4180 CSV quoting, archive scanning, torn
tails, and corrupt records.
Diffstat (limited to 'indra/newview')
32 files changed, 4147 insertions, 141 deletions
diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 0b62bdf0b0..622ebceac0 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -127,6 +127,8 @@ set(viewer_SOURCE_FILES llchathistory.cpp llchatitemscontainerctrl.cpp llchatmsgbox.cpp + llchatservicehistory.cpp + llchatservicehistorycore.cpp llchiclet.cpp llchicletbar.cpp llclassifiedinfo.cpp @@ -810,6 +812,8 @@ set(viewer_HEADER_FILES llchathistory.h llchatitemscontainerctrl.h llchatmsgbox.h + llchatservicehistory.h + llchatservicehistorycore.h llchiclet.h llchicletbar.h llclassifiedinfo.h @@ -2333,6 +2337,7 @@ if (LL_TESTS) include(LLAddBuildTest) SET(viewer_TEST_SOURCE_FILES llagentaccess.cpp + llchatservicehistorycore.cpp lldateutil.cpp # llmediadataclient.cpp lllogininstance.cpp @@ -2461,4 +2466,3 @@ if (LL_TESTS) endif (LL_TESTS) check_message_template(${VIEWER_BINARY_NAME}) - diff --git a/indra/newview/app_settings/settings.xml b/indra/newview/app_settings/settings.xml index 4f7826adb8..ac026b80bd 100644 --- a/indra/newview/app_settings/settings.xml +++ b/indra/newview/app_settings/settings.xml @@ -16495,6 +16495,17 @@ <key>Value</key> <integer>0</integer> </map> +<key>ChatServiceEnabled</key> + <map> + <key>Comment</key> + <string>Enable accumulated direct-message history from ChatService for this login.</string> + <key>Persist</key> + <integer>1</integer> + <key>Type</key> + <string>Boolean</string> + <key>Value</key> + <integer>1</integer> + </map> <key>SingleModeDoubleClickOpenWindow</key> <map> <key>Comment</key> diff --git a/indra/newview/llappviewer.cpp b/indra/newview/llappviewer.cpp index 2519fbffbb..a1f1f96257 100644 --- a/indra/newview/llappviewer.cpp +++ b/indra/newview/llappviewer.cpp @@ -239,6 +239,7 @@ #include "lldrawpoolbump.h" #include "llvieweraudio.h" #include "llimview.h" +#include "llchatservicehistory.h" #include "llviewerthrottle.h" #include "llparcel.h" #include "llavatariconctrl.h" @@ -5424,6 +5425,7 @@ void LLAppViewer::idleShutdown() // All floaters are closed. Tell server we want to quit. if (!logoutRequestSent()) { + LLChatServiceHistory::stop(); sendLogoutRequest(); // Wait for a LogoutReply message @@ -5772,6 +5774,8 @@ void LLAppViewer::idleNetwork() void LLAppViewer::disconnectViewer() { + LLChatServiceHistory::stop(); + if (gDisconnected) { return; @@ -6105,6 +6109,7 @@ void LLAppViewer::handleLoginComplete() gDebugInfo["MainloopTimeoutState"] = LLAppViewer::instance()->mMainloopTimeout->getState(); } + LLChatServiceHistory::start(); mOnLoginCompleted(); writeDebugInfo(); diff --git a/indra/newview/llavataractions.cpp b/indra/newview/llavataractions.cpp index fb1426a235..c8d80f3dd5 100644 --- a/indra/newview/llavataractions.cpp +++ b/indra/newview/llavataractions.cpp @@ -42,6 +42,7 @@ #include "llconversationlog.h" #include "llfloateravatarpicker.h" // for LLFloaterAvatarPicker #include "llfloaterconversationpreview.h" +#include "llchatservicehistory.h" #include "llfloatergroupinvite.h" #include "llfloatergroups.h" #include "llfloaterreg.h" @@ -1240,6 +1241,7 @@ void LLAvatarActions::inviteToGroup(const LLUUID& id) // static void LLAvatarActions::viewChatHistory(const LLUUID& id) { + if (LLChatServiceHistory::historySuppressed()) return; const std::vector<LLConversation>& conversations = LLConversationLog::instance().getConversations(); std::vector<LLConversation>::const_iterator iter = conversations.begin(); @@ -1252,7 +1254,7 @@ void LLAvatarActions::viewChatHistory(const LLUUID& id) } } - if (LLLogChat::isTranscriptExist(id)) + if (LLLogChat::isTranscriptExist(id) || LLChatServiceHistory::localHistoryExists(id)) { LLAvatarName avatar_name; LLSD extended_id(id); @@ -1260,6 +1262,7 @@ void LLAvatarActions::viewChatHistory(const LLUUID& id) LLAvatarNameCache::get(id, &avatar_name); extended_id[LL_FCP_COMPLETE_NAME] = avatar_name.getCompleteName(); extended_id[LL_FCP_ACCOUNT_NAME] = avatar_name.getAccountName(); + extended_id[LL_FCP_PARTICIPANT_ID] = id; LLFloaterReg::showInstance("preview_conversation", extended_id, true); } } diff --git a/indra/newview/llchathistory.cpp b/indra/newview/llchathistory.cpp index c1af09ebc7..2a8acbedff 100644 --- a/indra/newview/llchathistory.cpp +++ b/indra/newview/llchathistory.cpp @@ -31,6 +31,7 @@ #include <boost/signals2.hpp> #include "llavatarnamecache.h" +#include "llchatservicehistory.h" #include "llinstantmessage.h" #include "llimview.h" @@ -976,7 +977,10 @@ protected: menu->setItemVisible("Zoom In", should_show_zoom && gObjectList.findObject(mAvatarID)); menu->setItemEnabled("Block Unblock", LLAvatarActions::canBlock(mAvatarID)); menu->setItemEnabled("Mute Text", LLAvatarActions::canBlock(mAvatarID)); - menu->setItemEnabled("Chat History", LLLogChat::isTranscriptExist(mAvatarID)); + menu->setItemEnabled("Chat History", + !LLChatServiceHistory::historySuppressed() && + (LLLogChat::isTranscriptExist(mAvatarID) || + LLChatServiceHistory::localHistoryExists(mAvatarID))); } menu->setItemEnabled("Map", (LLAvatarTracker::instance().isBuddyOnline(mAvatarID) && is_agent_mappable(mAvatarID)) || gAgent.isGodlike() ); diff --git a/indra/newview/llchatservicehistory.cpp b/indra/newview/llchatservicehistory.cpp new file mode 100644 index 0000000000..2351a73655 --- /dev/null +++ b/indra/newview/llchatservicehistory.cpp @@ -0,0 +1,2346 @@ +/** + * @file llchatservicehistory.cpp + * @brief ChatService direct-IM history synchronization and stitched reads. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * $/LicenseInfo$ + */ +#include "llviewerprecompiledheaders.h" + +#include "llchatservicehistory.h" + +#include "llagent.h" +#include "llavatarnamecache.h" +#include "llcorehttputil.h" +#include "llcoros.h" +#include "lldate.h" +#include "lldir.h" +#include "lldiriterator.h" +#include "lleventcoro.h" +#include "llevents.h" +#include "llfile.h" +#include "llfloaterconversationpreview.h" +#include "llfloaterreg.h" +#include "llfloaterimsessiontab.h" +#include "llimview.h" +#include "lllogchat.h" +#include "llmutelist.h" +#include "llnotificationsutil.h" +#include "llsdserialize.h" +#include "lltimer.h" +#include "llviewercontrol.h" +#include "llviewerregion.h" +#include "workqueue.h" + +#if LL_WINDOWS +#include "llwin32headers.h" +#else +#include <fcntl.h> +#include <sys/stat.h> +#include <unistd.h> +#endif + +#include <algorithm> +#include <boost/date_time/gregorian/gregorian.hpp> +#include <boost/date_time/posix_time/posix_time.hpp> +#include <cerrno> +#include <climits> +#include <cstdio> +#include <deque> +#include <map> +#include <set> +#include <sstream> + +struct LLChatServiceHistoryAccess +{ + static void loadLegacy(const std::string& path, std::list<LLSD>& messages, + const LLSD& parameters) + { + LLLogChat::loadChatHistoryExactUnchecked(path, messages, parameters); + } +}; + +namespace +{ +using namespace LLChatServiceHistoryCore; + +const char* const LIST_CAP = "PersistentChatConversationsRequest"; +const char* const HISTORY_CAP = "PersistentChatHistoryRequest"; +const char* const ENABLED_SETTING = "ChatServiceEnabled"; +const char* const INDEX_NAME = "chat_service_index.csv"; +const char* const STATE_NAME = "chat_service_state.xml"; +const char* const PENDING_NAME = "(name pending)"; +const F64 REQUEST_SPACING = 2.1; +const F64 RETRY_DELAY = 5.0; +const F64 RATE_LIMIT_DELAY = 120.0; +const F64 LIST_INTERVAL = 3600.0; +const F64 NAME_TIMEOUT = 30.0; +const U64 UUID_EPOCH = 122192928000000000ULL; +const U64 UUID_TICK_LIMIT = (1ULL << 60); +const U32 MAX_PASS_ROWS = 50000; +const size_t MAX_PASS_BYTES = 64 * 1024 * 1024; + +enum EStateSafety { STATE_UNKNOWN, STATE_SAFE, STATE_UNSAFE }; +enum EMetadata { META_UNREQUESTED, META_PENDING, META_RESOLVED, META_FAILED }; +enum ESummary { SUMMARY_ABSENT, SUMMARY_UNPREPARED, SUMMARY_VALID }; + +struct CapabilityContext +{ + LLUUID region_id; + std::string list_url; + std::string history_url; + + bool complete() const { return region_id.notNull() && !list_url.empty() && !history_url.empty(); } + bool operator==(const CapabilityContext& rhs) const + { + return region_id == rhs.region_id && list_url == rhs.list_url && + history_url == rhs.history_url; + } + bool operator!=(const CapabilityContext& rhs) const { return !(*this == rhs); } +}; + +struct Summary +{ + ESummary state = SUMMARY_UNPREPARED; + U32 rows = 0; + bool has_rows = false; + bool file_exists = false; + U64 file_size = 0; + S64 file_mtime = 0; + TimeUuidKey oldest; + TimeUuidKey newest; +}; + +struct Metadata +{ + EMetadata state = META_UNREQUESTED; + U32 attempt = 0; + F64 deadline = 0.0; + LLAvatarName name; + boost::signals2::connection connection; +}; + +struct Resident +{ + std::string conversation_id; + std::string advertised_token; + std::string covered_token; + U32 covered_serial = 0; + U32 archive_serial = 0; + Summary summary; + Metadata metadata; + bool listed = false; + bool force_head = false; + bool forced_followup = false; + bool first_request_started = false; + bool retry_used = false; + bool metadata_waiting = false; + bool priority_waiting = false; + LLChatServiceHistory::Snapshot snapshot; +}; + +struct StateResult +{ + EStateSafety safety = STATE_UNSAFE; + U64 boundary = 0; + bool cleanup_pending = false; +}; + +struct HttpResult +{ + S32 status = 0; + LLSD body; +}; + +struct Runtime +{ + U32 epoch = 0; + bool running = false; + bool rollout = false; + bool wake_pending = false; + bool initialized_archives = false; + bool list_valid = false; + bool list_needed = true; + bool list_retry_used = false; + bool delete_requested = false; + bool delete_active = false; + bool index_dirty = false; + bool local_content_exists = false; + U64 delete_click_ticks = 0; + U64 deleted_before_ticks = 0; + EStateSafety state_safety = STATE_UNKNOWN; + bool cleanup_pending = false; + F64 network_not_before = 0.0; + F64 next_list = 0.0; + CapabilityContext context; + std::string account_dir; + std::string delimiter; + LLUUID agent_id; + LLUUID active_resident; + LLUUID priority_resident; + std::deque<LLUUID> queue; + std::map<LLUUID, Resident> residents; + LLChatServiceHistory::delete_callback_t delete_callback; + boost::signals2::connection consent_connection; + boost::signals2::connection show_history_connection; +}; + +Runtime sRuntime; +LLMutex sStorageMutex; +std::unique_ptr<LLEventMailDrop> sWake; +boost::signals2::signal<void(const LLUUID&, const LLChatServiceHistory::Snapshot&)> sSnapshotSignal; +boost::signals2::connection sRegionConnection; + +bool ownsRuntime(U32 epoch) +{ + return sRuntime.running && sRuntime.epoch == epoch; +} + +std::string accountPath(const std::string& filename) +{ + return gDirUtilp ? gDirUtilp->getExpandedFilename(LL_PATH_PER_SL_ACCOUNT, filename) + : std::string(); +} + +std::string childPath(const std::string& directory, const std::string& delimiter, + const std::string& filename) +{ + if (directory.empty() || filename.empty()) return directory + filename; + if (directory.back() == '/' || directory.back() == '\\') return directory + filename; + return directory + delimiter + filename; +} + +std::string archiveName(const LLUUID& resident_id) +{ + return "chat_service_" + resident_id.asString() + ".csv"; +} + +std::string archivePath(const std::string& directory, const std::string& delimiter, + const LLUUID& resident_id) +{ + return childPath(directory, delimiter, archiveName(resident_id)); +} + +bool transcriptConsent() +{ + return gSavedPerAccountSettings.getS32("KeepConversationLogTranscripts") > 1; +} + +CapabilityContext sampleContext() +{ + CapabilityContext result; + LLViewerRegion* region = gAgent.getRegion(); + if (region && region->capabilitiesReceived()) + { + result.region_id = region->getRegionID(); + result.list_url = region->getCapability(LIST_CAP); + result.history_url = region->getCapability(HISTORY_CAP); + } + return result; +} + +void wakeManager() +{ + if (sRuntime.running && sWake && !sRuntime.wake_pending) + { + sRuntime.wake_pending = true; + sWake->post(LLSD(true)); + } +} + +void publishSnapshot(const LLUUID& id, Resident& resident) +{ + resident.snapshot.archive_serial = resident.archive_serial; + resident.snapshot.metadata_resolved = resident.metadata.state == META_RESOLVED; + if (resident.snapshot.metadata_resolved) + { + resident.snapshot.metadata = resident.metadata.name; + } + resident.snapshot.service_presentation_allowed = + sRuntime.rollout && transcriptConsent() && + sRuntime.state_safety == STATE_SAFE && !sRuntime.cleanup_pending && + !sRuntime.delete_requested; + sSnapshotSignal(id, resident.snapshot); +} + +void setWorkActive(const LLUUID& id, bool active) +{ + Resident& resident = sRuntime.residents[id]; + if (resident.snapshot.service_work_active != active) + { + resident.snapshot.service_work_active = active; + publishSnapshot(id, resident); + } +} + +void queueResident(const LLUUID& id, bool priority) +{ + sRuntime.queue.erase(std::remove(sRuntime.queue.begin(), sRuntime.queue.end(), id), + sRuntime.queue.end()); + if (priority) + { + sRuntime.queue.push_front(id); + } + else + { + sRuntime.queue.push_back(id); + } +} + +void clearPriority(const LLUUID& id) +{ + if (sRuntime.priority_resident == id) sRuntime.priority_resident.setNull(); +} + +void failResidentPass(const LLUUID& id, Resident& resident) +{ + resident.snapshot.head_preview.clear(); + resident.snapshot.service_work_active = false; + publishSnapshot(id, resident); + clearPriority(id); +} + +bool parseDecimalTicks(const std::string& text, U64& value) +{ + if (text.empty() || (text.size() > 1 && text[0] == '0') || + text.find_first_not_of("0123456789") != std::string::npos) + { + return false; + } + U64 parsed = 0; + for (char ch : text) + { + const U64 digit = static_cast<U64>(ch - '0'); + if (parsed > (UUID_TICK_LIMIT - 1 - digit) / 10) + { + return false; + } + parsed = parsed * 10 + digit; + } + value = parsed; + return true; +} + +bool inspectRegular(const std::string& path, bool& exists) +{ + exists = false; +#if LL_WINDOWS + const std::wstring wide = ll_convert<std::wstring>(path); + const DWORD attributes = GetFileAttributesW(wide.c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) + { + return GetLastError() == ERROR_FILE_NOT_FOUND || GetLastError() == ERROR_PATH_NOT_FOUND; + } + exists = true; + return !(attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)); +#else + struct stat status; + if (::lstat(path.c_str(), &status) != 0) + { + return errno == ENOENT; + } + exists = true; + return S_ISREG(status.st_mode); +#endif +} + +bool syncDirectory(const std::string& path) +{ +#if LL_WINDOWS + return true; +#else + const std::string::size_type separator = path.find_last_of("/\\"); + if (separator == std::string::npos) return false; + const std::string directory = path.substr(0, separator); + const int descriptor = ::open(directory.c_str(), O_RDONLY | O_DIRECTORY); + if (descriptor < 0) + { + return false; + } + const bool success = ::fsync(descriptor) == 0; + ::close(descriptor); + return success; +#endif +} + +bool syncFile(const std::string& path) +{ +#if LL_WINDOWS + HANDLE handle = CreateFileW(ll_convert<std::wstring>(path).c_str(), GENERIC_WRITE, + FILE_SHARE_READ, NULL, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, NULL); + if (handle == INVALID_HANDLE_VALUE || !FlushFileBuffers(handle)) + { + if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); + return false; + } + CloseHandle(handle); + return true; +#else + const int descriptor = ::open(path.c_str(), O_RDONLY); + if (descriptor < 0 || ::fsync(descriptor) != 0) + { + if (descriptor >= 0) ::close(descriptor); + return false; + } + ::close(descriptor); + return true; +#endif +} + +bool writeReplace(const std::string& destination, const std::string& contents, bool durable) +{ + const std::string temporary = destination + ".tmp"; + bool exists = false; + if (!inspectRegular(destination, exists) || !inspectRegular(temporary, exists)) + { + return false; + } + llofstream output(temporary.c_str(), std::ios::binary | std::ios::trunc); + if (!output.is_open()) + { + return false; + } + output.write(contents.data(), contents.size()); + output.flush(); + output.close(); + if (output.fail()) + { + return false; + } + + // Privacy state is flushed before replacement; its directory entry is committed afterward. + if (durable) + { + if (!syncFile(temporary)) return false; + } +#if LL_WINDOWS + const std::wstring from = ll_convert<std::wstring>(temporary); + const std::wstring to = ll_convert<std::wstring>(destination); + if (!MoveFileExW(from.c_str(), to.c_str(), MOVEFILE_REPLACE_EXISTING | + (durable ? MOVEFILE_WRITE_THROUGH : 0))) + { + return false; + } +#else + if (::rename(temporary.c_str(), destination.c_str()) != 0) + { + return false; + } +#endif + return !durable || syncDirectory(destination); +} + +bool replacePrefix(const std::string& path, std::streamoff bytes) +{ + const std::string temporary = path + ".tmp"; + bool exists = false; + if (bytes < 0 || !inspectRegular(path, exists) || !exists || + !inspectRegular(temporary, exists)) return false; + llifstream input(path.c_str(), std::ios::binary); + llofstream output(temporary.c_str(), std::ios::binary | std::ios::trunc); + std::array<char, 64 * 1024> buffer; + std::streamoff remaining = bytes; + while (input.is_open() && output.is_open() && remaining > 0) + { + const std::streamsize count = static_cast<std::streamsize>( + llmin<std::streamoff>(remaining, buffer.size())); + input.read(buffer.data(), count); + if (input.gcount() != count) return false; + output.write(buffer.data(), count); + remaining -= count; + } + output.flush(); + output.close(); + const bool input_ok = input.is_open() && !input.bad(); + input.close(); + if (!input_ok || output.fail() || remaining) return false; +#if LL_WINDOWS + const std::wstring from = ll_convert<std::wstring>(temporary); + const std::wstring to = ll_convert<std::wstring>(path); + return MoveFileExW(from.c_str(), to.c_str(), MOVEFILE_REPLACE_EXISTING) != 0; +#else + return ::rename(temporary.c_str(), path.c_str()) == 0; +#endif +} + +bool readStateFile(const std::string& path, StateResult& state) +{ + bool exists = false; + if (!inspectRegular(path, exists) || !exists) + { + return false; + } + llifstream input(path.c_str(), std::ios::binary); + LLSD data; + if (!input.is_open()) return false; + const S32 parsed = LLSDSerialize::fromXMLDocument(data, input, false); + input.clear(); + input >> std::ws; + if (parsed == LLSDParser::PARSE_FAILURE || input.peek() != EOF || + !data.isMap() || data.size() != 2 || + !data["deleted_before_uuid_ticks"].isString() || + !data["cleanup_pending"].isBoolean() || + !parseDecimalTicks(data["deleted_before_uuid_ticks"].asString(), state.boundary)) + { + return false; + } + state.safety = STATE_SAFE; + state.cleanup_pending = data["cleanup_pending"].asBoolean(); + return true; +} + +StateResult loadState(const std::string& path) +{ + StateResult result; + const std::string temporary = path + ".tmp"; + bool canonical_exists = false; + bool temporary_exists = false; + const bool canonical_safe = inspectRegular(path, canonical_exists); + const bool temporary_safe = inspectRegular(temporary, temporary_exists); + if (canonical_safe && canonical_exists && readStateFile(path, result)) + { + // A committed canonical is authoritative; discard any interrupted replacement. + if ((!temporary_safe || temporary_exists) && + ((LLFile::remove(temporary, ENOENT) != 0 && errno != ENOENT) || + !syncDirectory(path))) + { + result.safety = STATE_UNSAFE; + } + return result; + } + if (canonical_safe && temporary_safe && !canonical_exists && !temporary_exists) + { + result.safety = STATE_SAFE; + return result; + } + result.safety = STATE_UNSAFE; + return result; +} + +U64 recoverBoundaryCandidate(const std::string& path) +{ + bool exists = false; + if (!inspectRegular(path, exists) || !exists) return 0; + llifstream input(path.c_str(), std::ios::binary); + LLSD data; + U64 boundary = 0; + if (input.is_open() && + LLSDSerialize::fromXML(data, input, false) != LLSDParser::PARSE_FAILURE && + data.isMap() && data["deleted_before_uuid_ticks"].isString()) + { + parseDecimalTicks(data["deleted_before_uuid_ticks"].asString(), boundary); + } + return boundary; +} + +bool recoverStateForDelete(const std::string& path, U64 boundary) +{ + const std::string temporary = path + ".tmp"; + const std::string::size_type separator = path.find_last_of("/\\"); + const std::string recovery = path.substr(0, separator + 1) + INDEX_NAME + ".tmp"; + LLSD data; + data["deleted_before_uuid_ticks"] = std::to_string(boundary); + data["cleanup_pending"] = true; + std::ostringstream serialized; + if (!LLSDSerialize::toPrettyXML(data, serialized)) return false; + + // Use an owned index temporary so an unsafe state entry remains until replacement. + if (LLFile::remove(recovery, ENOENT) != 0 && errno != ENOENT) return false; + llofstream output(recovery.c_str(), std::ios::binary | std::ios::trunc); + if (!output.is_open()) return false; + output << serialized.str(); + output.flush(); + output.close(); + if (output.fail()) return false; + if (!syncFile(recovery)) return false; +#if LL_WINDOWS + if (!MoveFileExW(ll_convert<std::wstring>(recovery).c_str(), + ll_convert<std::wstring>(temporary).c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) return false; +#else + if (::rename(recovery.c_str(), temporary.c_str()) != 0 || + !syncDirectory(temporary)) return false; +#endif + if (LLFile::remove(path, ENOENT) != 0 && errno != ENOENT) return false; +#if LL_WINDOWS + if (!MoveFileExW(ll_convert<std::wstring>(temporary).c_str(), + ll_convert<std::wstring>(path).c_str(), MOVEFILE_WRITE_THROUGH)) return false; +#else + if (::rename(temporary.c_str(), path.c_str()) != 0 || !syncDirectory(path)) return false; +#endif + return true; +} + +bool writeState(const std::string& path, U64 boundary, bool pending) +{ + LLSD data; + data["deleted_before_uuid_ticks"] = std::to_string(boundary); + data["cleanup_pending"] = pending; + std::ostringstream serialized; + if (!LLSDSerialize::toPrettyXML(data, serialized)) + { + return false; + } + return writeReplace(path, serialized.str(), true); +} + +bool clearUnsafeStateTemporary(const std::string& path) +{ + const std::string temporary = path + ".tmp"; + bool exists = false; + if (inspectRegular(temporary, exists)) return true; + if (LLFile::remove(temporary, ENOENT) != 0 && errno != ENOENT) return false; + if (!inspectRegular(temporary, exists) || exists) return false; + return syncDirectory(path); +} + +bool canonicalArchiveName(const std::string& name, LLUUID& resident_id) +{ + const std::string prefix = "chat_service_"; + const std::string suffix = ".csv"; + if (name.size() != prefix.size() + 36 + suffix.size() || + name.compare(0, prefix.size(), prefix) || + name.compare(name.size() - suffix.size(), suffix.size(), suffix)) + { + return false; + } + return parseCanonicalUuid(name.substr(prefix.size(), 36), resident_id); +} + +bool ownedArtifactName(const std::string& name) +{ + if (name == INDEX_NAME || name == std::string(INDEX_NAME) + ".tmp" || + name == STATE_NAME || name == std::string(STATE_NAME) + ".tmp") + { + return true; + } + const std::string prefix = "chat_service_"; + if (name.size() <= prefix.size() + 36 || name.compare(0, prefix.size(), prefix)) + { + return false; + } + LLUUID id; + if (!parseCanonicalUuid(name.substr(prefix.size(), 36), id)) + { + return false; + } + const std::string suffix = name.substr(prefix.size() + 36); + return suffix == ".csv" || suffix == ".csv.tmp" || suffix == ".csv.corrupt"; +} + +Summary summaryFromScan(const ArchiveScan& scan) +{ + Summary result; + result.state = scan.state == ARCHIVE_ABSENT ? SUMMARY_ABSENT : SUMMARY_VALID; + result.rows = scan.row_count; + result.has_rows = scan.has_oldest; + result.file_exists = scan.state != ARCHIVE_ABSENT; + result.file_size = scan.file_size; + result.file_mtime = scan.file_mtime; + result.oldest = scan.oldest; + result.newest = scan.newest; + return result; +} + +bool prepareArchive(const std::string& path, const LLUUID& resident_id, const LLUUID& agent_id, + U64 boundary, Summary& summary, bool& bytes_changed) +{ + LLMutexLock lock(&sStorageMutex); + bool canonical_exists = false; + if (!inspectRegular(path, canonical_exists)) return false; + ArchiveScan scan; + if (scanArchive(path, agent_id, resident_id, boundary, 0, scan)) + { + summary = summaryFromScan(scan); + return true; + } + if (scan.state == ARCHIVE_TORN) + { + U64 current_size = 0; + S64 current_mtime = 0; + if (!archiveStamp(path, current_size, current_mtime) || + current_size != scan.file_size || current_mtime != scan.file_mtime || + !replacePrefix(path, scan.valid_prefix_bytes) || + !scanArchive(path, agent_id, resident_id, boundary, 0, scan)) + { + summary.state = SUMMARY_UNPREPARED; + return false; + } + bytes_changed = true; + summary = summaryFromScan(scan); + return true; + } + if (scan.state != ARCHIVE_CORRUPT) + { + summary.state = SUMMARY_UNPREPARED; + return false; + } + + // Preserve one structurally unusable canonical before a later full-window rebuild. + const std::string corrupt = path + ".corrupt"; + bool corrupt_exists = false; + if (!inspectRegular(corrupt, corrupt_exists)) + { + if (LLFile::remove(corrupt, ENOENT) != 0 && errno != ENOENT) + return false; + if (!inspectRegular(corrupt, corrupt_exists) || corrupt_exists) + return false; + } + if (LLFile::remove(corrupt, ENOENT) != 0 && errno != ENOENT) + { + summary.state = SUMMARY_UNPREPARED; + return false; + } + U64 current_size = 0; + S64 current_mtime = 0; + if (!archiveStamp(path, current_size, current_mtime) || + current_size != scan.file_size || current_mtime != scan.file_mtime || + LLFile::rename(path, corrupt) != 0) + { + summary.state = SUMMARY_UNPREPARED; + return false; + } + bytes_changed = true; + summary = Summary(); + summary.state = SUMMARY_ABSENT; + return true; +} + +bool publishRows(const std::string& path, const std::vector<Row>& rows, + bool append, Summary& resulting) +{ + LLMutexLock lock(&sStorageMutex); + if (rows.empty()) + { + return true; + } + bool exists = false; + if (!inspectRegular(path, exists) || exists != resulting.file_exists) return false; + if (exists) + { + U64 file_size = 0; + S64 file_mtime = 0; + if (!archiveStamp(path, file_size, file_mtime) || + file_size != resulting.file_size || file_mtime != resulting.file_mtime) return false; + } + if (!append) + { + std::ostringstream output; + output << CSV_HEADER; + for (const Row& row : rows) + { + writeCsvRow(output, row); + } + if (!writeReplace(path, output.str(), false)) + { + return false; + } + } + else + { + llstat status; + bool needs_separator = false; + if (LLFile::stat(path, &status) != 0 || !LLFile::isfile(path)) + { + return false; + } + if (status.st_size) + { + llifstream input(path.c_str(), std::ios::binary); + input.seekg(-1, std::ios::end); + needs_separator = input.get() != '\n'; + } + llofstream output(path.c_str(), std::ios::binary | std::ios::app); + if (!output.is_open()) + { + return false; + } + if (needs_separator) output << '\n'; + for (const Row& row : rows) writeCsvRow(output, row); + output.flush(); + output.close(); + if (output.fail()) return false; + } + resulting.state = SUMMARY_VALID; + resulting.rows += static_cast<U32>(rows.size()); + if (!resulting.has_rows) + { + resulting.oldest = rows.front().key; + resulting.has_rows = true; + } + resulting.newest = rows.back().key; + resulting.file_exists = true; + return archiveStamp(path, resulting.file_size, resulting.file_mtime); +} + +std::vector<LLUUID> enumerateArchives(const std::string& directory, + const std::string& delimiter) +{ + std::vector<LLUUID> result; + LLDirIterator iterator(directory, "chat_service_*.csv"); + std::string name; + while (iterator.next(name)) + { + LLUUID id; + if (canonicalArchiveName(name, id)) result.push_back(id); + } + std::sort(result.begin(), result.end()); + result.erase(std::unique(result.begin(), result.end()), result.end()); + return result; +} + +bool hasOwnedContent(const std::string& directory) +{ + LLDirIterator iterator(directory, "chat_service_*"); + std::string name; + while (iterator.next(name)) + { + if (ownedArtifactName(name) && name != INDEX_NAME && + name != std::string(INDEX_NAME) + ".tmp" && name != STATE_NAME && + name != std::string(STATE_NAME) + ".tmp") + { + return true; + } + } + return false; +} + +bool regenerateIndex(const std::vector<LLUUID>& ids, + const std::map<LLUUID, LLAvatarName>& names, + const std::string& directory, const std::string& delimiter) +{ + LLMutexLock lock(&sStorageMutex); + const std::string index = childPath(directory, delimiter, INDEX_NAME); + const std::string temporary = index + ".tmp"; + for (const std::string& path : { index, temporary }) + { + bool exists = false; + const bool regular = inspectRegular(path, exists); + if (!regular || (path == temporary && exists)) + { + if (LLFile::remove(path, ENOENT) != 0 && errno != ENOENT) + return false; + if (!inspectRegular(path, exists) || exists) return false; + } + } + std::vector<std::pair<std::string, LLUUID>> archives; + for (const LLUUID& id : ids) + { + bool exists = false; + if (inspectRegular(archivePath(directory, delimiter, id), exists) && exists) + { + archives.emplace_back(archiveName(id), id); + } + } + if (archives.empty()) + { + const int removed = LLFile::remove(index, ENOENT); + return removed == 0 || errno == ENOENT; + } + std::ostringstream output; + output << INDEX_HEADER; + for (const auto& archive : archives) + { + const auto found = names.find(archive.second); + const bool resolved = found != names.end(); + output << quoteCsv(archive.first) << ',' << archive.second.asString() << ',' + << quoteCsv(resolved ? found->second.getAccountName() : PENDING_NAME) << ',' + << quoteCsv(resolved ? found->second.getDisplayName() : PENDING_NAME) << '\n'; + } + return writeReplace(index, output.str(), false); +} + +HttpResult request(const std::string& url, const LLSD* post) +{ + HttpResult result; + sRuntime.network_not_before = llmax(sRuntime.network_not_before, + F64(LLTimer::getTotalSeconds()) + REQUEST_SPACING); + LLCoreHttpUtil::HttpCoroutineAdapter::ptr_t adapter = + std::make_shared<LLCoreHttpUtil::HttpCoroutineAdapter>( + "ChatServiceHistory", LLCore::HttpRequest::DEFAULT_POLICY_ID); + LLCore::HttpRequest::ptr_t http_request = std::make_shared<LLCore::HttpRequest>(); + LLCore::HttpOptions::ptr_t options = std::make_shared<LLCore::HttpOptions>(); + options->setRetries(0); + options->setTimeout(30); + LLSD response = post ? adapter->postAndSuspend(http_request, url, *post, options) + : adapter->getAndSuspend(http_request, url, options); + const LLCore::HttpStatus status = + LLCoreHttpUtil::HttpCoroutineAdapter::getStatusFromLLSD( + response[LLCoreHttpUtil::HttpCoroutineAdapter::HTTP_RESULTS]); + result.status = status.getType(); + if (status) + { + result.body = response.has(LLCoreHttpUtil::HttpCoroutineAdapter::HTTP_RESULTS_CONTENT) + ? response[LLCoreHttpUtil::HttpCoroutineAdapter::HTTP_RESULTS_CONTENT] + : response; + if (result.body.isMap()) + { + result.body.erase(LLCoreHttpUtil::HttpCoroutineAdapter::HTTP_RESULTS); + } + } + return result; +} + +bool retryable(S32 status) +{ + return status < 100 || status == 408 || status >= 500; +} + +bool nameBlocked(const LLUUID& id, const Resident& resident) +{ + LLMuteList* mute = LLMuteList::getInstance(); + if (!mute || !mute->isLoadedFromServer() || mute->isMuted(id)) + { + return true; + } + return resident.metadata.state != META_RESOLVED || + mute->isMuted(id, resident.metadata.name.getUserName()); +} + +bool uuidBlocked(const LLUUID& id) +{ + LLMuteList* mute = LLMuteList::getInstance(); + return !mute || !mute->isLoadedFromServer() || mute->isMuted(id); +} + +U32 pendingMetadataCount() +{ + U32 count = 0; + for (const auto& pair : sRuntime.residents) + { + count += pair.second.metadata.state == META_PENDING; + } + return count; +} + +bool ensureMetadata(const LLUUID& id, Resident& resident) +{ + // A resolved record is stable for this login. Republishing it from a view load + // would synchronously re-enter that load through the resident snapshot signal. + if (resident.metadata.state == META_RESOLVED) + { + return true; + } + + LLAvatarName cached; + if (LLAvatarNameCache::get(id, &cached)) + { + resident.metadata.state = META_RESOLVED; + resident.metadata.name = cached; + sRuntime.index_dirty = true; + publishSnapshot(id, resident); + return true; + } + if (resident.metadata.state == META_PENDING || resident.metadata.state == META_FAILED || + pendingMetadataCount() >= 8) + { + return false; + } + + // Mark the attempt first because a cache hit may deliver synchronously from get(). + resident.metadata.state = META_PENDING; + resident.metadata.deadline = F64(LLTimer::getTotalSeconds()) + NAME_TIMEOUT; + const U32 attempt = ++resident.metadata.attempt; + const U32 epoch = sRuntime.epoch; + boost::signals2::connection connection = LLAvatarNameCache::get( + id, [id, epoch, attempt](const LLUUID&, const LLAvatarName& name) + { + if (!sRuntime.running || sRuntime.epoch != epoch) return; + auto found = sRuntime.residents.find(id); + if (found == sRuntime.residents.end() || + found->second.metadata.state != META_PENDING || + found->second.metadata.attempt != attempt) + { + return; + } + found->second.metadata.connection.disconnect(); + found->second.metadata.state = META_RESOLVED; + found->second.metadata.name = name; + sRuntime.index_dirty = true; + if (found->second.metadata_waiting) + { + queueResident(id, found->second.priority_waiting); + found->second.metadata_waiting = false; + found->second.priority_waiting = false; + } + publishSnapshot(id, found->second); + wakeManager(); + }); + if (resident.metadata.state == META_PENDING && resident.metadata.attempt == attempt) + { + resident.metadata.connection = connection; + } + else + { + connection.disconnect(); + } + return resident.metadata.state == META_RESOLVED; +} + +bool baseNetworkEligible(const CapabilityContext& context) +{ + return sRuntime.running && sRuntime.rollout && transcriptConsent() && + sRuntime.state_safety == STATE_SAFE && !sRuntime.cleanup_pending && + !sRuntime.delete_requested && context.complete() && + LLMuteList::getInstance() && LLMuteList::getInstance()->isLoadedFromServer(); +} + +bool networkEligible(const LLUUID& id, const Resident& resident, + const CapabilityContext& context) +{ + return baseNetworkEligible(context) && id.notNull() && !nameBlocked(id, resident); +} + +void expireMetadata(); + +bool waitForWake(F64 seconds, U32 epoch) +{ + if (!ownsRuntime(epoch) || !sWake) return false; + sRuntime.wake_pending = false; + sWake->discard(); + llcoro::suspendUntilEventOnWithTimeout(*sWake, static_cast<F32>(llmax(0.01, seconds)), + LLSDMap("timeout", true)); + if (!ownsRuntime(epoch)) return false; + sRuntime.wake_pending = false; + return true; +} + +bool pace(const CapabilityContext& context, U32 epoch, + const LLUUID& resident_id = LLUUID::null) +{ + while (ownsRuntime(epoch)) + { + if (sampleContext() != context || sRuntime.delete_requested) + { + return false; + } + if (resident_id.notNull()) + { + auto found = sRuntime.residents.find(resident_id); + if (found == sRuntime.residents.end() || + !networkEligible(resident_id, found->second, context)) + { + return false; + } + } + const F64 now = F64(LLTimer::getTotalSeconds()); + const F64 remaining = sRuntime.network_not_before - now; + if (remaining <= 0.0) + { + return true; + } + F64 wait = remaining; + for (const auto& pair : sRuntime.residents) + { + if (pair.second.metadata.state == META_PENDING) + wait = llmin(wait, llmax(0.01, pair.second.metadata.deadline - now)); + } + const U32 pending_before = pendingMetadataCount(); + if (!waitForWake(wait, epoch)) return false; + expireMetadata(); + if (pendingMetadataCount() < pending_before && pendingMetadataCount() < 8) + { + // Give the manager a turn to fill a newly-open name slot before cooldown ends. + if (resident_id.notNull()) queueResident(resident_id, + sRuntime.priority_resident == resident_id); + return false; + } + } + return false; +} + +void handleRequestFailure(const LLUUID& id, Resident& resident, S32 status) +{ + if (status == 429) + { + // One manager-wide cooldown survives repeated throttles and every priority trigger. + sRuntime.network_not_before = llmax(sRuntime.network_not_before, + F64(LLTimer::getTotalSeconds()) + RATE_LIMIT_DELAY); + queueResident(id, sRuntime.priority_resident == id); + setWorkActive(id, true); + return; + } + if (retryable(status) && !resident.retry_used) + { + resident.retry_used = true; + sRuntime.network_not_before = llmax(sRuntime.network_not_before, + F64(LLTimer::getTotalSeconds()) + RETRY_DELAY); + queueResident(id, sRuntime.priority_resident == id); + } + else + { + setWorkActive(id, false); + clearPriority(id); + } +} + +bool processList(const LLSD& body) +{ + std::vector<ListEntry> entries; + if (!validateConversationList(body, sRuntime.agent_id, entries)) + { + return false; + } + std::set<LLUUID> listed; + for (const ListEntry& entry : entries) listed.insert(entry.resident_id); + + // Validate the complete list before preserving priority placeholders and appending background work. + for (auto& pair : sRuntime.residents) + { + pair.second.listed = listed.count(pair.first) != 0; + if (!pair.second.listed) + { + sRuntime.queue.erase(std::remove(sRuntime.queue.begin(), sRuntime.queue.end(), pair.first), + sRuntime.queue.end()); + if (sRuntime.priority_resident == pair.first) sRuntime.priority_resident.setNull(); + pair.second.metadata_waiting = false; + pair.second.priority_waiting = false; + setWorkActive(pair.first, false); + } + } + const bool first_list = !sRuntime.list_valid; + for (const ListEntry& entry : entries) + { + Resident& resident = sRuntime.residents[entry.resident_id]; + const bool placeholder = std::find(sRuntime.queue.begin(), sRuntime.queue.end(), + entry.resident_id) != sRuntime.queue.end(); + const bool changed = resident.advertised_token != entry.last_msg_id; + resident.conversation_id = entry.conversation_id; + resident.advertised_token = entry.last_msg_id; + resident.listed = true; + if (resident.metadata.state == META_FAILED) + resident.metadata.state = META_UNREQUESTED; + if (!placeholder && (first_list || changed || resident.force_head || + resident.covered_token != resident.advertised_token || + resident.covered_serial != resident.archive_serial)) + { + resident.retry_used = false; + queueResident(entry.resident_id, false); + } + } + sRuntime.list_valid = true; + return true; +} + +void requestList(const CapabilityContext& context, U32 epoch) +{ + if (!pace(context, epoch)) return; + if (!baseNetworkEligible(context)) return; + const HttpResult response = request(context.list_url, NULL); + if (!ownsRuntime(epoch) || sampleContext() != context || sRuntime.delete_requested || + !baseNetworkEligible(context)) return; + if (response.status == 429) + { + sRuntime.network_not_before = llmax(sRuntime.network_not_before, + F64(LLTimer::getTotalSeconds()) + RATE_LIMIT_DELAY); + sRuntime.list_needed = true; + return; + } + if (!response.body.isUndefined() && response.status >= 200 && response.status < 300 && + processList(response.body)) + { + sRuntime.list_needed = false; + sRuntime.list_retry_used = false; + sRuntime.next_list = F64(LLTimer::getTotalSeconds()) + LIST_INTERVAL; + return; + } + if (retryable(response.status) && !sRuntime.list_retry_used) + { + sRuntime.list_retry_used = true; + sRuntime.network_not_before = llmax(sRuntime.network_not_before, + F64(LLTimer::getTotalSeconds()) + RETRY_DELAY); + sRuntime.list_needed = true; + } + else + { + sRuntime.list_needed = false; + sRuntime.next_list = F64(LLTimer::getTotalSeconds()) + LIST_INTERVAL; + } +} + +void syncResident(const LLUUID& id, const CapabilityContext& context, U32 epoch) +{ + auto found = sRuntime.residents.find(id); + if (found == sRuntime.residents.end()) return; + bool needs_prepare = false; + bool had_boundary = false; + TimeUuidKey stored_newest; + { + Resident& resident = found->second; + if (!resident.listed || resident.conversation_id.empty()) + { + // A failed discovery remains dormant until a fresh open/list/region trigger. + resident.snapshot.head_preview.clear(); + setWorkActive(id, false); + clearPriority(id); + return; + } + if (uuidBlocked(id)) + { + setWorkActive(id, false); + clearPriority(id); + return; + } + if (!ensureMetadata(id, resident)) + { + resident.metadata_waiting = true; + resident.priority_waiting = resident.priority_waiting || sRuntime.priority_resident == id; + return; + } + resident.metadata_waiting = false; + resident.priority_waiting = false; + if (!networkEligible(id, resident, context)) + { + setWorkActive(id, false); + clearPriority(id); + return; + } + setWorkActive(id, true); + sRuntime.active_resident = id; + resident.first_request_started = false; + needs_prepare = resident.summary.state == SUMMARY_UNPREPARED; + had_boundary = !needs_prepare && resident.summary.state == SUMMARY_VALID && + resident.summary.has_rows; + stored_newest = resident.summary.newest; + } + std::vector<Row> staged; + std::set<std::string> pass_ids; + size_t staged_bytes = 0; + U32 returned_rows = 0; + std::string cursor; + bool complete = false; + bool exposed_preview = false; + while (!complete) + { + if (!pace(context, epoch, id)) + { + if (!ownsRuntime(epoch)) return; + found = sRuntime.residents.find(id); + if (found == sRuntime.residents.end()) return; + Resident& suspended = found->second; + suspended.snapshot.head_preview.clear(); + setWorkActive(id, false); + sRuntime.active_resident.setNull(); + LLMuteList* mute = LLMuteList::getInstance(); + const bool already_queued = + std::find(sRuntime.queue.begin(), sRuntime.queue.end(), id) != + sRuntime.queue.end(); + const bool temporarily_ineligible = !sRuntime.delete_requested && + (sampleContext() != context || !baseNetworkEligible(context) || + !mute || !mute->isLoadedFromServer()); + if (!already_queued) + { + if (temporarily_ineligible) + queueResident(id, sRuntime.priority_resident == id); + else + clearPriority(id); + } + return; + } + LLSD post; + { + found = sRuntime.residents.find(id); + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + Resident& current = found->second; + if (sRuntime.priority_resident.notNull() && sRuntime.priority_resident != id) + { + queueResident(id, false); + staged.clear(); + break; + } + current.first_request_started = true; + post["conversation_id"] = current.conversation_id; + post["limit"] = 100; + if (!cursor.empty()) post["before_msg_id"] = cursor; + } + const HttpResult response = request(context.history_url, &post); + Page page; + found = sRuntime.residents.find(id); + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + { + Resident& after_request = found->second; + if (sRuntime.delete_requested || sampleContext() != context || + !networkEligible(id, after_request, context)) + { + break; + } + if (response.status < 200 || response.status >= 300) + { + handleRequestFailure(id, after_request, response.status); + after_request.snapshot.head_preview.clear(); + publishSnapshot(id, after_request); + sRuntime.active_resident.setNull(); + return; + } + if (!validateHistoryPage(response.body, sRuntime.agent_id, id, + after_request.conversation_id, cursor, + sRuntime.deleted_before_ticks, page)) + { + failResidentPass(id, after_request); + break; + } + + // A different priority takes effect only after this response validates. + if (sRuntime.priority_resident.notNull() && sRuntime.priority_resident != id) + { + queueResident(id, false); + staged.clear(); + break; + } + if (!exposed_preview && !page.rows.empty()) + { + if (!networkEligible(id, after_request, context)) break; + after_request.snapshot.head_preview = page.rows; + exposed_preview = true; + publishSnapshot(id, after_request); + } + } + + // The first validated page is visible before a potentially years-long archive scan. + if (needs_prepare) + { + bool bytes_changed = false; + Summary summary; + const std::string path = archivePath(sRuntime.account_dir, sRuntime.delimiter, id); + const LLUUID agent_id = sRuntime.agent_id; + const U64 boundary = sRuntime.deleted_before_ticks; + LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); + const bool prepared = general && general->waitForResult( + [path, id, agent_id, boundary, &summary, &bytes_changed]() + { + return prepareArchive(path, id, agent_id, boundary, summary, bytes_changed); + }); + found = sRuntime.residents.find(id); + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + { + Resident& after_prepare = found->second; + if (!prepared) + { + failResidentPass(id, after_prepare); + sRuntime.active_resident.setNull(); + return; + } + needs_prepare = false; + after_prepare.summary = summary; + sRuntime.index_dirty = true; + had_boundary = summary.state == SUMMARY_VALID && summary.has_rows; + stored_newest = summary.newest; + if (bytes_changed) + { + ++after_prepare.archive_serial; + after_prepare.covered_token.clear(); + sRuntime.index_dirty = true; + sRuntime.local_content_exists = true; + publishSnapshot(id, after_prepare); + } + if (sRuntime.priority_resident.notNull() && sRuntime.priority_resident != id) + { + queueResident(id, false); + staged.clear(); + break; + } + } + } + + // Archive preparation can suspend across logout; never retain its old Resident reference. + found = sRuntime.residents.find(id); + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + Resident& page_resident = found->second; + bool reached_stored_newest = false; + for (const Row& row : page.rows) + { + if (!pass_ids.insert(row.msg_id).second) + { + staged.clear(); + complete = false; + failResidentPass(id, page_resident); + sRuntime.active_resident.setNull(); + return; + } + ++returned_rows; + if (returned_rows > MAX_PASS_ROWS) + { + staged.clear(); + failResidentPass(id, page_resident); + sRuntime.active_resident.setNull(); + return; + } + if (had_boundary && row.key <= stored_newest) + { + reached_stored_newest = true; + break; + } + const size_t bytes = row.conversation_id.size() + row.msg_id.size() + 36 + + row.from_name.size() + row.message.size() + std::to_string(row.dialog).size() + + row.created_at.size(); + if (staged_bytes + bytes > MAX_PASS_BYTES) + { + staged.clear(); + failResidentPass(id, page_resident); + sRuntime.active_resident.setNull(); + return; + } + staged_bytes += bytes; + staged.push_back(row); + } + complete = page.terminal || reached_stored_newest; + if (!complete) + { + if (page.next_cursor.empty()) + { + staged.clear(); + break; + } + cursor = page.next_cursor; + } + } + + if (!complete) + { + found = sRuntime.residents.find(id); + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + Resident& final_resident = found->second; + final_resident.snapshot.head_preview.clear(); + if (std::find(sRuntime.queue.begin(), sRuntime.queue.end(), id) == sRuntime.queue.end()) + { + failResidentPass(id, final_resident); + } + else publishSnapshot(id, final_resident); + sRuntime.active_resident.setNull(); + return; + } + std::sort(staged.begin(), staged.end(), [](const Row& left, const Row& right) + { + return left.key < right.key; + }); + found = sRuntime.residents.find(id); + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + Resident& final_resident = found->second; + if (!networkEligible(id, final_resident, context) || sRuntime.delete_requested || + sampleContext() != context) + { + final_resident.snapshot.head_preview.clear(); + setWorkActive(id, false); + clearPriority(id); + sRuntime.active_resident.setNull(); + return; + } + + bool changed = false; + bool publication_failed = false; + if (!staged.empty()) + { + // The final gate precedes the one bounded mutation job; later gate changes stop follow-up. + Summary resulting = final_resident.summary; + const bool append = final_resident.summary.state == SUMMARY_VALID && final_resident.summary.has_rows; + const std::string account_dir = sRuntime.account_dir; + const std::string path = archivePath(account_dir, sRuntime.delimiter, id); + if (!ownsRuntime(epoch) || sRuntime.account_dir != account_dir || + sampleContext() != context || sRuntime.delete_requested || + !networkEligible(id, final_resident, context)) + { + return; + } + LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); + changed = general && general->waitForResult( + [path, staged, append, &resulting]() mutable + { + return publishRows(path, staged, append, resulting); + }); + found = sRuntime.residents.find(id); + if (!ownsRuntime(epoch) || sRuntime.account_dir != account_dir || + found == sRuntime.residents.end()) return; + Resident& after_publish = found->second; + if (changed) + { + after_publish.summary = resulting; + ++after_publish.archive_serial; + sRuntime.index_dirty = true; + sRuntime.local_content_exists = true; + LLLogChat::notifyTranscriptCreated(); + } + else + { + // Append/replace failure may have changed bytes; invalidate every reader token. + ++after_publish.archive_serial; + after_publish.summary.state = SUMMARY_UNPREPARED; + after_publish.covered_token.clear(); + sRuntime.index_dirty = true; + sRuntime.local_content_exists = true; + LLLogChat::notifyTranscriptCreated(); + publication_failed = true; + } + } + found = sRuntime.residents.find(id); + if (!ownsRuntime(epoch) || found == sRuntime.residents.end()) return; + Resident& applied = found->second; + if (sRuntime.delete_requested) + { + applied.snapshot.head_preview.clear(); + sRuntime.active_resident.setNull(); + return; + } + if (changed || staged.empty()) + { + applied.covered_token = applied.advertised_token; + applied.covered_serial = applied.archive_serial; + } + if (!publication_failed) + { + applied.retry_used = false; + applied.force_head = false; + } + applied.snapshot.head_preview.clear(); + publishSnapshot(id, applied); + if (applied.forced_followup && networkEligible(id, applied, context)) + { + applied.forced_followup = false; + queueResident(id, true); + } + else + { + setWorkActive(id, false); + } + if (sRuntime.priority_resident == id) sRuntime.priority_resident.setNull(); + sRuntime.active_resident.setNull(); +} + +bool sweepServiceArtifacts(const std::string& directory, const std::string& delimiter, + const std::string& state_path) +{ + LLMutexLock lock(&sStorageMutex); + LLDirIterator iterator(directory, "chat_service_*"); + std::string name; + std::vector<std::string> targets; + while (iterator.next(name)) + { + if (ownedArtifactName(name) && name != STATE_NAME) + { + targets.push_back(childPath(directory, delimiter, name)); + } + } + // State temporary is non-authoritative once pending has committed and is swept explicitly. + targets.push_back(state_path + ".tmp"); + bool success = true; + for (const std::string& path : targets) + { + if (LLFile::remove(path, ENOENT) != 0 && errno != ENOENT) success = false; + bool exists = false; + if (!inspectRegular(path, exists) || exists) success = false; + } + if (!syncDirectory(state_path)) success = false; + return success; +} + +void finishDelete(bool success) +{ + LLChatServiceHistory::delete_callback_t callback = sRuntime.delete_callback; + sRuntime.delete_callback.clear(); + sRuntime.delete_active = false; + if (success) + { + sRuntime.delete_requested = false; + sRuntime.cleanup_pending = false; + sRuntime.state_safety = STATE_SAFE; + sRuntime.list_needed = true; + } + else + { + sRuntime.delete_requested = true; + LLNotificationsUtil::add("ChatServiceHistoryDeleteFailed"); + } + if (callback) callback(success); + LLLogChat::notifyTranscriptCreated(); +} + +void runDelete(U32 epoch) +{ + const std::string state_path = childPath(sRuntime.account_dir, sRuntime.delimiter, STATE_NAME); + U64 boundary = sRuntime.delete_click_ticks; + + // Publish pending state before invalidating views or removing any local content. + { + LLMutexLock lock(&sStorageMutex); + boundary = llmax(boundary, llmax(recoverBoundaryCandidate(state_path), + recoverBoundaryCandidate(state_path + ".tmp"))); + StateResult existing; + const bool canonical_valid = readStateFile(state_path, existing); + if ((canonical_valid && + (!clearUnsafeStateTemporary(state_path) || + !writeState(state_path, boundary, true))) || + (!canonical_valid && !recoverStateForDelete(state_path, boundary))) + { + sRuntime.state_safety = STATE_UNSAFE; + finishDelete(false); + return; + } + } + sRuntime.deleted_before_ticks = boundary; + sRuntime.cleanup_pending = true; + sRuntime.queue.clear(); + sRuntime.active_resident.setNull(); + sRuntime.priority_resident.setNull(); + for (auto& pair : sRuntime.residents) + { + pair.second.metadata.connection.disconnect(); + pair.second.snapshot.head_preview.clear(); + pair.second.snapshot.service_work_active = false; + publishSnapshot(pair.first, pair.second); + } + for (auto& pair : LLIMModel::instance().mId2SessionMap) + pair.second->clearForHistoryDeletion(); + const LLFloaterReg::const_instance_list_t& previews = + LLFloaterReg::getFloaterList("preview_conversation"); + for (LLFloater* floater : previews) + { + if (LLFloaterConversationPreview* preview = + dynamic_cast<LLFloaterConversationPreview*>(floater)) + { + preview->invalidateHistory(); + preview->closeFloater(); + } + } + LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); + + LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); + const std::string chat_logs_dir = + gDirUtilp ? gDirUtilp->getPerAccountChatLogsDir() : std::string(); + const std::string account_dir = sRuntime.account_dir; + const std::string delimiter = sRuntime.delimiter; + const bool legacy = general && general->waitForResult([chat_logs_dir]() + { + return !chat_logs_dir.empty() && LLLogChat::deleteTranscriptContent(chat_logs_dir); + }); + if (!ownsRuntime(epoch)) return; + const bool service = legacy && general->waitForResult( + [account_dir, delimiter, state_path]() + { + return sweepServiceArtifacts(account_dir, delimiter, state_path); + }); + if (!ownsRuntime(epoch)) return; + if (!service) + { + finishDelete(false); + return; + } + if (!ownsRuntime(epoch)) return; + { + LLMutexLock lock(&sStorageMutex); + if (!writeState(state_path, boundary, false)) + { + sRuntime.state_safety = STATE_UNSAFE; + finishDelete(false); + return; + } + } + if (!ownsRuntime(epoch)) return; + sRuntime.residents.clear(); + sRuntime.initialized_archives = true; + sRuntime.local_content_exists = false; + finishDelete(true); +} + +bool initializeArchives(U32 epoch) +{ + LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); + const std::string directory = sRuntime.account_dir; + const std::string delimiter = sRuntime.delimiter; + typedef std::pair<std::vector<LLUUID>, bool> initial_artifacts_t; + const initial_artifacts_t artifacts = general + ? general->waitForResult([directory, delimiter]() + { + return initial_artifacts_t(enumerateArchives(directory, delimiter), + hasOwnedContent(directory)); + }) + : initial_artifacts_t(); + if (!ownsRuntime(epoch)) return false; + sRuntime.local_content_exists = artifacts.second; + const std::vector<LLUUID>& ids = artifacts.first; + for (const LLUUID& id : ids) + { + sRuntime.residents[id].summary.state = SUMMARY_UNPREPARED; + } + sRuntime.initialized_archives = true; + sRuntime.index_dirty = true; + return true; +} + +bool prepareOneArchive(U32 epoch) +{ + for (auto& pair : sRuntime.residents) + { + Resident& resident = pair.second; + if (resident.summary.state != SUMMARY_UNPREPARED) continue; + const LLUUID id = pair.first; + const std::string path = archivePath(sRuntime.account_dir, sRuntime.delimiter, id); + const LLUUID agent_id = sRuntime.agent_id; + const U64 boundary = sRuntime.deleted_before_ticks; + bool changed = false; + Summary summary; + LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); + const bool prepared = general && general->waitForResult( + [path, id, agent_id, boundary, &summary, &changed]() + { + return prepareArchive(path, id, agent_id, boundary, summary, changed); + }); + if (!ownsRuntime(epoch)) return false; + auto found = sRuntime.residents.find(id); + if (found == sRuntime.residents.end()) return false; + Resident& current = found->second; + if (prepared) + { + current.summary = summary; + // Prepared validity changes index membership even when no bytes required repair. + sRuntime.index_dirty = true; + if (changed) + { + ++current.archive_serial; + } + if (summary.has_rows) ensureMetadata(id, current); + publishSnapshot(id, current); + LLLogChat::notifyTranscriptCreated(); + } + return prepared; + } + return false; +} + +void fillMetadataSlots() +{ + while (pendingMetadataCount() < 8) + { + auto found = std::find_if(sRuntime.residents.begin(), sRuntime.residents.end(), + [](const auto& pair) + { + return pair.second.priority_waiting && + pair.second.metadata.state == META_UNREQUESTED; + }); + if (found == sRuntime.residents.end()) + { + found = std::find_if(sRuntime.residents.begin(), sRuntime.residents.end(), + [](const auto& pair) + { + return (pair.second.metadata_waiting || + pair.second.summary.state == SUMMARY_VALID) && + pair.second.metadata.state == META_UNREQUESTED; + }); + } + if (found == sRuntime.residents.end()) return; + if (ensureMetadata(found->first, found->second)) + { + if (found->second.metadata_waiting) + queueResident(found->first, found->second.priority_waiting); + found->second.metadata_waiting = false; + found->second.priority_waiting = false; + } + } +} + +bool updateIndex(U32 epoch) +{ + std::vector<LLUUID> archives; + std::map<LLUUID, LLAvatarName> names; + for (const auto& pair : sRuntime.residents) + { + if (pair.second.summary.state == SUMMARY_VALID && + pair.second.summary.file_exists) + { + archives.push_back(pair.first); + } + if (pair.second.metadata.state == META_RESOLVED) + names[pair.first] = pair.second.metadata.name; + } + LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); + const bool updated = general && general->waitForResult( + [archives, names, directory = sRuntime.account_dir, + delimiter = sRuntime.delimiter]() + { + return regenerateIndex(archives, names, directory, delimiter); + }); + if (!ownsRuntime(epoch)) return false; + if (updated) + { + std::vector<LLUUID> current_archives; + std::vector<LLUUID> current_names; + for (const auto& pair : sRuntime.residents) + { + if (pair.second.summary.state == SUMMARY_VALID && + pair.second.summary.file_exists) + current_archives.push_back(pair.first); + if (pair.second.metadata.state == META_RESOLVED) + current_names.push_back(pair.first); + } + std::vector<LLUUID> indexed_names; + for (const auto& pair : names) indexed_names.push_back(pair.first); + sRuntime.index_dirty = archives != current_archives || indexed_names != current_names; + return true; + } + return false; +} + +void expireMetadata() +{ + const F64 now = LLTimer::getTotalSeconds(); + for (auto& pair : sRuntime.residents) + { + Metadata& metadata = pair.second.metadata; + if (metadata.state == META_PENDING && metadata.deadline <= now) + { + metadata.connection.disconnect(); + metadata.state = META_FAILED; + pair.second.metadata_waiting = false; + pair.second.priority_waiting = false; + setWorkActive(pair.first, false); + clearPriority(pair.first); + } + } +} + +F64 nearestWait() +{ + const F64 now = LLTimer::getTotalSeconds(); + F64 deadline = sRuntime.next_list > now ? sRuntime.next_list : now + LIST_INTERVAL; + if (sRuntime.network_not_before > now) deadline = llmin(deadline, sRuntime.network_not_before); + for (const auto& pair : sRuntime.residents) + { + if (pair.second.metadata.state == META_PENDING) + deadline = llmin(deadline, pair.second.metadata.deadline); + } + return llmax(0.01, deadline - now); +} + +void manager(U32 epoch) +{ + while (sRuntime.running && sRuntime.epoch == epoch) + { + if (sRuntime.delete_active) + { + runDelete(epoch); + if (!ownsRuntime(epoch)) return; + continue; + } + if (sRuntime.state_safety == STATE_UNKNOWN) + { + LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); + const std::string state_path = childPath(sRuntime.account_dir, + sRuntime.delimiter, STATE_NAME); + const StateResult state = general + ? general->waitForResult([state_path]() + { + LLMutexLock lock(&sStorageMutex); + return loadState(state_path); + }) + : StateResult(); + if (!sRuntime.running || sRuntime.epoch != epoch) return; + sRuntime.state_safety = state.safety; + sRuntime.deleted_before_ticks = state.boundary; + sRuntime.cleanup_pending = state.cleanup_pending; + LLLogChat::notifyTranscriptCreated(); + if (state.safety == STATE_SAFE && !state.cleanup_pending) + { + LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); + } + if (state.safety == STATE_SAFE && state.cleanup_pending) + { + sRuntime.delete_active = true; + sRuntime.delete_requested = true; + sRuntime.delete_click_ticks = state.boundary; + continue; + } + } + if (sRuntime.state_safety == STATE_SAFE && !sRuntime.initialized_archives) + { + if (!initializeArchives(epoch)) return; + continue; + } + expireMetadata(); + fillMetadataSlots(); + + const CapabilityContext context = sampleContext(); + if (context != sRuntime.context) + { + sRuntime.context = context; + if (context.complete()) + { + sRuntime.list_needed = true; + sRuntime.list_retry_used = false; + for (auto& pair : sRuntime.residents) + { + if (pair.second.metadata.state == META_FAILED) + pair.second.metadata.state = META_UNREQUESTED; + } + } + else + { + for (auto& pair : sRuntime.residents) setWorkActive(pair.first, false); + } + } + const bool base_network = baseNetworkEligible(context); + if (base_network) + { + if (F64(LLTimer::getTotalSeconds()) >= sRuntime.next_list) + { + sRuntime.list_needed = true; + sRuntime.list_retry_used = false; + } + if (sRuntime.list_needed) + { + requestList(context, epoch); + if (!ownsRuntime(epoch)) return; + continue; + } + if (!sRuntime.queue.empty()) + { + const LLUUID id = sRuntime.queue.front(); + sRuntime.queue.pop_front(); + syncResident(id, context, epoch); + if (!ownsRuntime(epoch)) return; + continue; + } + + // Existing archives share the same bounded resolver used by blocking and views. + for (auto& pair : sRuntime.residents) + { + if (pair.second.summary.has_rows && pair.second.metadata.state == META_UNREQUESTED && + pendingMetadataCount() < 8) + { + ensureMetadata(pair.first, pair.second); + } + } + } + else + { + sRuntime.active_resident.setNull(); + for (auto& pair : sRuntime.residents) + setWorkActive(pair.first, false); + } + // Storage maintenance yields to every eligible priority/network occurrence. + if (sRuntime.state_safety == STATE_SAFE && !sRuntime.cleanup_pending && + prepareOneArchive(epoch)) + { + continue; + } + if (!ownsRuntime(epoch)) return; + if (sRuntime.index_dirty && sRuntime.state_safety == STATE_SAFE && + !sRuntime.cleanup_pending) + { + if (!ownsRuntime(epoch)) return; + if (updateIndex(epoch)) continue; + if (!ownsRuntime(epoch)) return; + } + if (!waitForWake(nearestWait(), epoch)) return; + } +} + +bool clickTicks(U64& ticks) +{ + const U64 sampled = LLTimer::getTotalTime(); + if (sampled > static_cast<U64>(LLONG_MAX)) return false; + const S64 offset = static_cast<S64>(gUTCOffset) * 1000000LL; + S64 corrected = static_cast<S64>(sampled); + if ((offset > 0 && corrected > LLONG_MAX - offset) || + (offset < 0 && corrected < LLONG_MIN - offset)) + { + return false; + } + corrected += offset; + if (corrected < 0 || static_cast<U64>(corrected) > + (UUID_TICK_LIMIT - 1 - UUID_EPOCH - 9) / 10) + { + return false; + } + ticks = UUID_EPOCH + static_cast<U64>(corrected) * 10 + 9; + return ticks < UUID_TICK_LIMIT; +} + +bool legacyWallEpoch(const std::string& text, F64& epoch) +{ + if (text.size() < 15) return false; + int year = 0, month = 0, day = 0, hour = 0, minute = 0; + char suffix[3] = {}; + int consumed = 0; + if (std::sscanf(text.c_str(), "%d/%d/%d %d:%d %2s%n", &year, &month, &day, + &hour, &minute, suffix, &consumed) == 6) + { + if (consumed != static_cast<int>(text.size()) || + (strcmp(suffix, "AM") && strcmp(suffix, "PM")) || hour < 1 || hour > 12) + return false; + if (!strcmp(suffix, "AM")) hour %= 12; + else if (hour != 12) hour += 12; + } + else + { + consumed = 0; + if (std::sscanf(text.c_str(), "%d/%d/%d %d:%d%n", &year, &month, &day, + &hour, &minute, &consumed) != 5 || + consumed != static_cast<int>(text.size()) || hour < 0 || hour > 23) + return false; + } + if (minute < 0 || minute > 59) + return false; + try + { + const boost::posix_time::ptime value( + boost::gregorian::date(year, month, day), + boost::posix_time::hours(hour) + boost::posix_time::minutes(minute)); + static const boost::posix_time::ptime unix_epoch(boost::gregorian::date(1970, 1, 1)); + epoch = static_cast<F64>((value - unix_epoch).total_seconds()); + return true; + } + catch (...) + { + return false; + } +} + +LLSD serviceMessage(const Row& row) +{ + LLSD message; + + // Keep the validated wire value as metadata while rendering through the viewer's + // SLT transcript format. + const U32 timestamp = static_cast<U32>(LLDate(row.created_at).secondsSinceEpoch()); + message[LL_IM_TIME] = LLLogChat::timestamp2LogString(timestamp, true); + message[LL_IM_DATE_TIME] = row.created_at; + message[LL_IM_FROM] = row.from_name; + message[LL_IM_FROM_ID] = row.from_id; + message[LL_IM_TEXT] = row.message; + message["is_history"] = true; + message["chat_service_msg_id"] = row.msg_id; + return message; +} + +LLChatServiceHistory::HistoryResult readStitched( + const LLUUID& id, const LLUUID& agent_id, const std::string& archive_path, + const std::vector<std::string>& legacy_paths, U32 limit, U32 epoch, U32 serial, + U64 boundary, bool include_service, std::vector<Row> preview) +{ + LLChatServiceHistory::HistoryResult result; + result.account_epoch = epoch; + result.archive_serial = serial; + result.included_service = include_service; + ArchiveScan archive; + if (include_service) + { + // View reads serialize with publication but never repair or quarantine storage. + LLMutexLock lock(&sStorageMutex); + if (!scanArchive(archive_path, agent_id, id, boundary, limit, archive) && + archive.state != ARCHIVE_ABSENT) + { + result.maintenance_needed = true; + } + if (archive.state != ARCHIVE_VALID) archive = ArchiveScan(); + } + + std::list<LLSD> legacy; + LLSD parameters; + parameters["load_all_history"] = true; + parameters["cut_off_todays_date"] = false; + for (const std::string& path : legacy_paths) + { + LLChatServiceHistoryAccess::loadLegacy(path, legacy, parameters); + } + + // Only the durable archive's oldest key controls the legacy/service seam. + const F64 service_epoch = archive.has_oldest + ? static_cast<F64>(archive.oldest.ticks - UUID_EPOCH) / 10000000.0 : 0.0; + std::vector<std::pair<F64, LLSD>> dated; + std::list<LLSD> undated; + for (const LLSD& message : legacy) + { + F64 wall = 0.0; + if (!legacyWallEpoch(message[LL_IM_DATE_TIME].asString(), wall)) + { + undated.push_back(message); + } + else if (!archive.has_oldest || wall + 7.0 * 3600.0 < service_epoch) + { + dated.emplace_back(wall, message); + } + } + std::stable_sort(dated.begin(), dated.end(), + [](const auto& left, const auto& right) { return left.first < right.first; }); + for (const auto& item : dated) result.messages.push_back(item.second); + for (const LLSD& message : undated) result.messages.push_back(message); + + std::set<std::string> canonical_ids; + std::vector<Row> service = archive.display_rows; + for (const Row& row : service) canonical_ids.insert(row.msg_id); + for (const Row& row : preview) + { + if (!canonical_ids.count(row.msg_id)) service.push_back(row); + } + std::sort(service.begin(), service.end(), [](const Row& left, const Row& right) + { + return left.key < right.key; + }); + for (const Row& row : service) result.messages.push_back(serviceMessage(row)); + while (limit && result.messages.size() > limit) result.messages.pop_front(); + return result; +} +} + +void LLChatServiceHistory::start() +{ + stop(); + ++sRuntime.epoch; + sRuntime.running = true; + sRuntime.rollout = gSavedSettings.getBOOL(ENABLED_SETTING); + sRuntime.account_dir = accountPath(""); + sRuntime.delimiter = gDirUtilp ? gDirUtilp->getDirDelimiter() : std::string("/"); + sRuntime.agent_id = gAgentID; + sRuntime.state_safety = STATE_UNKNOWN; + sRuntime.next_list = 0.0; + sRuntime.list_needed = true; + if (!sWake) + { + sWake.reset(new LLEventMailDrop("ChatServiceHistoryWake", true)); + } + sWake->discard(); + sRegionConnection = gAgent.addRegionChangedCallback([]() + { + LLChatServiceHistory::regionChanged(); + }); + if (gSavedPerAccountSettings.controlExists("KeepConversationLogTranscripts")) + { + sRuntime.consent_connection = gSavedPerAccountSettings + .getControl("KeepConversationLogTranscripts")->getSignal()->connect( + [](LLControlVariable*, const LLSD&, const LLSD&) + { + const bool allowed = transcriptConsent(); + for (auto& pair : sRuntime.residents) + { + if (!allowed) + { + pair.second.snapshot.head_preview.clear(); + pair.second.snapshot.service_work_active = false; + } + publishSnapshot(pair.first, pair.second); + } + wakeManager(); + }); + } + if (gSavedPerAccountSettings.controlExists("LogShowHistory")) + { + sRuntime.show_history_connection = gSavedPerAccountSettings + .getControl("LogShowHistory")->getSignal()->connect( + [](LLControlVariable*, const LLSD&, const LLSD&) + { + LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); + }); + } + regionChanged(); + const U32 epoch = sRuntime.epoch; + LLCoros::instance().launch("ChatServiceHistory", [epoch]() { manager(epoch); }); +} + +void LLChatServiceHistory::stop() +{ + if (sRuntime.running) + { + sRuntime.running = false; + ++sRuntime.epoch; + for (auto& pair : sRuntime.residents) pair.second.metadata.connection.disconnect(); + if (sWake) sWake->post(LLSD(true)); + } + sRegionConnection.disconnect(); + sRuntime.consent_connection.disconnect(); + sRuntime.show_history_connection.disconnect(); + const U32 epoch = sRuntime.epoch; + sRuntime = Runtime(); + sRuntime.epoch = epoch; +} + +void LLChatServiceHistory::regionChanged() +{ + LLViewerRegion* region = gAgent.getRegion(); + if (region && !region->capabilitiesReceived()) + { + region->setCapabilitiesReceivedCallback([](const LLUUID&, LLViewerRegion*) + { + wakeManager(); + }); + } + wakeManager(); +} + +bool LLChatServiceHistory::enabledForLogin() +{ + return sRuntime.rollout; +} + +U32 LLChatServiceHistory::accountEpoch() +{ + return sRuntime.epoch; +} + +bool LLChatServiceHistory::historySuppressed() +{ + return sRuntime.state_safety != STATE_SAFE || sRuntime.cleanup_pending || + sRuntime.delete_requested; +} + +bool LLChatServiceHistory::servicePresentationAllowed() +{ + return sRuntime.rollout && transcriptConsent() && !historySuppressed(); +} + +bool LLChatServiceHistory::localHistoryExists() +{ + if (sRuntime.delete_active) return false; + if (sRuntime.delete_requested || sRuntime.cleanup_pending || + sRuntime.state_safety == STATE_UNSAFE) return true; + for (const auto& pair : sRuntime.residents) + { + if (pair.second.summary.has_rows) return true; + } + return sRuntime.local_content_exists; +} + +bool LLChatServiceHistory::localHistoryExists(const LLUUID& resident_id) +{ + if (!servicePresentationAllowed()) return false; + const auto found = sRuntime.residents.find(resident_id); + if (found == sRuntime.residents.end() || found->second.summary.state == SUMMARY_UNPREPARED) + { + wakeManager(); + return false; + } + return found->second.summary.has_rows; +} + +bool LLChatServiceHistory::isPersistedDirectDialog(EInstantMessage dialog) +{ + return persistedDirectDialog(static_cast<S32>(dialog)); +} + +void LLChatServiceHistory::prioritizeResident(const LLUUID& id, bool inbound) +{ + if (!sRuntime.running || id.isNull()) return; + Resident& resident = sRuntime.residents[id]; + resident.force_head = true; + resident.retry_used = false; + if (resident.metadata.state == META_FAILED) resident.metadata.state = META_UNREQUESTED; + if (sRuntime.active_resident == id) + { + if (inbound && resident.first_request_started) resident.forced_followup = true; + } + else + { + queueResident(id, true); + sRuntime.priority_resident = id; + } + if (!resident.listed) + { + sRuntime.list_needed = true; + sRuntime.list_retry_used = false; + } + const CapabilityContext context = sampleContext(); + const bool potentially_active = sRuntime.rollout && transcriptConsent() && + sRuntime.state_safety == STATE_SAFE && !sRuntime.cleanup_pending && + !sRuntime.delete_requested && context.complete() && !uuidBlocked(id); + setWorkActive(id, potentially_active); + wakeManager(); +} + +LLChatServiceHistory::Snapshot LLChatServiceHistory::getSnapshot(const LLUUID& id) +{ + const auto found = sRuntime.residents.find(id); + if (found == sRuntime.residents.end()) + { + Snapshot snapshot; + snapshot.service_presentation_allowed = servicePresentationAllowed(); + return snapshot; + } + return found->second.snapshot; +} + +boost::signals2::connection LLChatServiceHistory::setSnapshotChanged( + const snapshot_callback_t& callback) +{ + return sSnapshotSignal.connect(callback); +} + +std::list<LLSD> LLChatServiceHistory::mergeHeadPreview( + const std::list<LLSD>& loaded, const Snapshot& snapshot, U32 limit) +{ + std::list<LLSD> result = loaded; + std::set<std::string> ids; + for (const LLSD& message : result) + { + if (message["chat_service_msg_id"].isString()) + ids.insert(message["chat_service_msg_id"].asString()); + } + std::vector<Row> additions; + for (const Row& row : snapshot.head_preview) + { + if (!ids.count(row.msg_id)) additions.push_back(row); + } + std::sort(additions.begin(), additions.end(), [](const Row& left, const Row& right) + { + return left.key < right.key; + }); + for (const Row& row : additions) result.push_back(serviceMessage(row)); + while (limit && result.size() > limit) result.pop_front(); + return result; +} + +bool LLChatServiceHistory::loadStitchedHistory( + const LLUUID& id, const std::string& legacy_stem, U32 limit, + const history_callback_t& callback) +{ + if (!callback || id.isNull() || historySuppressed()) return false; + Resident& resident = sRuntime.residents[id]; + const bool include_service = servicePresentationAllowed(); + if (include_service) ensureMetadata(id, resident); + const U32 epoch = sRuntime.epoch; + const U32 serial = resident.archive_serial; + const U64 boundary = sRuntime.deleted_before_ticks; + const std::vector<Row> preview = include_service + ? resident.snapshot.head_preview : std::vector<Row>(); + const LLUUID agent_id = sRuntime.agent_id; + const std::string archive_path = archivePath(sRuntime.account_dir, sRuntime.delimiter, id); + std::vector<std::string> legacy_paths; + LLLogChat::getTranscriptFamily(legacy_stem, legacy_paths); + LL::WorkQueue::ptr_t main = LL::WorkQueue::getInstance("mainloop"); + LL::WorkQueue::ptr_t general = LL::WorkQueue::getInstance("General"); + if (!main || !general) return false; + return main->postTo(general, + [id, agent_id, archive_path, legacy_paths, limit, epoch, serial, boundary, + include_service, preview]() + { + return readStitched(id, agent_id, archive_path, legacy_paths, limit, epoch, + serial, boundary, include_service, preview); + }, + [id, epoch, serial, callback](HistoryResult result) + { + if (result.maintenance_needed && sRuntime.epoch == epoch) + { + auto found = sRuntime.residents.find(id); + if (found != sRuntime.residents.end() && + found->second.archive_serial == serial) + { + found->second.summary.state = SUMMARY_UNPREPARED; + found->second.covered_token.clear(); + wakeManager(); + } + } + callback(result); + }); +} + +bool LLChatServiceHistory::deleteTranscriptsAsync(const delete_callback_t& callback) +{ + if (!sRuntime.running || sRuntime.delete_active || !callback) return false; + U64 ticks = 0; + if (!clickTicks(ticks)) return false; + sRuntime.delete_click_ticks = ticks; + sRuntime.delete_callback = callback; + sRuntime.delete_active = true; + sRuntime.delete_requested = true; + wakeManager(); + return true; +} diff --git a/indra/newview/llchatservicehistory.h b/indra/newview/llchatservicehistory.h new file mode 100644 index 0000000000..6280981e00 --- /dev/null +++ b/indra/newview/llchatservicehistory.h @@ -0,0 +1,72 @@ +/** + * @file llchatservicehistory.h + * @brief ChatService direct-IM history synchronization and stitched reads. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * $/LicenseInfo$ + */ +#ifndef LL_LLCHATSERVICEHISTORY_H +#define LL_LLCHATSERVICEHISTORY_H + +#include "llavatarname.h" +#include "llchatservicehistorycore.h" +#include "llinstantmessage.h" +#include "llsd.h" +#include "lluuid.h" + +#include <boost/function.hpp> +#include <boost/signals2/connection.hpp> +#include <list> +#include <vector> + +namespace LLChatServiceHistory +{ + struct Snapshot + { + bool service_work_active = false; + bool service_presentation_allowed = false; + U32 archive_serial = 0; + bool metadata_resolved = false; + LLAvatarName metadata; + std::vector<LLChatServiceHistoryCore::Row> head_preview; + }; + + struct HistoryResult + { + std::list<LLSD> messages; + U32 account_epoch = 0; + U32 archive_serial = 0; + bool included_service = false; + bool maintenance_needed = false; + }; + + typedef boost::function<void(const HistoryResult&)> history_callback_t; + typedef boost::function<void(bool)> delete_callback_t; + typedef boost::function<void(const LLUUID&, const Snapshot&)> snapshot_callback_t; + + void start(); + void stop(); + void regionChanged(); + + bool enabledForLogin(); + U32 accountEpoch(); + bool historySuppressed(); + bool servicePresentationAllowed(); + bool localHistoryExists(); + bool localHistoryExists(const LLUUID& resident_id); + + bool isPersistedDirectDialog(EInstantMessage dialog); + void prioritizeResident(const LLUUID& resident_id, bool inbound = false); + Snapshot getSnapshot(const LLUUID& resident_id); + boost::signals2::connection setSnapshotChanged(const snapshot_callback_t& callback); + std::list<LLSD> mergeHeadPreview(const std::list<LLSD>& loaded, + const Snapshot& snapshot, U32 limit); + + bool loadStitchedHistory(const LLUUID& resident_id, const std::string& legacy_stem, + U32 limit, const history_callback_t& callback); + bool deleteTranscriptsAsync(const delete_callback_t& callback); +} + +#endif diff --git a/indra/newview/llchatservicehistorycore.cpp b/indra/newview/llchatservicehistorycore.cpp new file mode 100644 index 0000000000..5f25347021 --- /dev/null +++ b/indra/newview/llchatservicehistorycore.cpp @@ -0,0 +1,580 @@ +/** + * @file llchatservicehistorycore.cpp + * @brief Strict ChatService wire, TimeUUID, and CSV primitives. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * $/LicenseInfo$ + */ +#include "llviewerprecompiledheaders.h" + +#include "llchatservicehistorycore.h" + +#include "lldate.h" +#include "llfile.h" +#include "llstring.h" +#include "fsyspath.h" + +#if LL_WINDOWS +#include "llwin32headers.h" +#else +#include <sys/stat.h> +#endif + +#include <algorithm> +#include <cerrno> +#include <cctype> +#include <limits> +#include <set> + +namespace LLChatServiceHistoryCore +{ +const char* const CSV_HEADER = + "conversation_id,msg_id,from_id,from_name,message,dialog,created_at\n"; +const char* const INDEX_HEADER = "filename,agent_id,agent_name,display_name\n"; + +namespace +{ + const U32 COLUMN_COUNT = 7; + + bool cleanText(const std::string& value, size_t limit) + { + return value.size() <= limit && value.find('\0') == std::string::npos && + wstring_to_utf8str(utf8str_to_wstring(value)) == value; + } + + bool decimalDialog(const std::string& text, S32& dialog) + { + if (text.empty() || (text.size() > 1 && text[0] == '0') || + text.find_first_not_of("0123456789") != std::string::npos) + { + return false; + } + return LLStringUtil::convertToS32(text, dialog) && persistedDirectDialog(dialog); + } + + bool rowFromFields(const std::vector<std::string>& fields, + const LLUUID& agent_id, const LLUUID& resident_id, + Row& row) + { + if (fields.size() != COLUMN_COUNT || + fields[0] != directConversationId(agent_id, resident_id) || + !parseTimeUuid(fields[1], row.key) || + !parseCanonicalUuid(fields[2], row.from_id) || + (row.from_id != agent_id && row.from_id != resident_id) || + !cleanText(fields[3], 256) || !cleanText(fields[4], 1024) || + !decimalDialog(fields[5], row.dialog) || + !parseCreatedAt(fields[6], row.created_at)) + { + return false; + } + row.conversation_id = fields[0]; + row.msg_id = fields[1]; + row.from_name = fields[3]; + row.message = fields[4]; + return true; + } + + // Decode RFC-4180 records incrementally so archive validation never needs a second parser. + bool readRecord(std::istream& input, std::vector<std::string>& fields, + bool& eof_torn, bool& had_record) + { + fields.clear(); + eof_torn = false; + had_record = false; + std::string field; + bool quoted = false; + bool quote_closed = false; + bool started = false; + for (;;) + { + const int raw = input.get(); + if (raw == EOF) + { + had_record = started; + eof_torn = started && (quoted || fields.size() + 1 < COLUMN_COUNT); + if (started && !eof_torn && !quoted) + { + fields.push_back(field); + } + return !input.bad(); + } + started = true; + const char ch = static_cast<char>(raw); + if (quoted) + { + if (ch != '"') + { + field.push_back(ch); + continue; + } + if (input.peek() == '"') + { + input.get(); + field.push_back('"'); + continue; + } + quoted = false; + quote_closed = true; + continue; + } + if (ch == '"') + { + if (!field.empty() || quote_closed) + { + return false; + } + quoted = true; + } + else if (ch == ',') + { + fields.push_back(field); + field.clear(); + quote_closed = false; + } + else if (ch == '\n' || ch == '\r') + { + if (ch == '\r' && input.peek() == '\n') + { + input.get(); + } + fields.push_back(field); + had_record = true; + return true; + } + else if (quote_closed) + { + return false; + } + else + { + field.push_back(ch); + } + } + } + + bool headerMatches(const std::vector<std::string>& fields) + { + static const std::vector<std::string> header{ + "conversation_id", "msg_id", "from_id", "from_name", + "message", "dialog", "created_at"}; + return fields == header; + } + + bool llsdString(const LLSD& map, const char* name, std::string& value) + { + const LLSD& field = map[name]; + if (!field.isString()) + { + return false; + } + value = field.asString(); + return true; + } + + bool inspectRegularFile(const std::string& path, bool& exists) + { + exists = false; +#if LL_WINDOWS + const DWORD attributes = GetFileAttributesW(ll_convert<std::wstring>(path).c_str()); + if (attributes == INVALID_FILE_ATTRIBUTES) + return GetLastError() == ERROR_FILE_NOT_FOUND || + GetLastError() == ERROR_PATH_NOT_FOUND; + exists = true; + return !(attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)); +#else + struct stat status; + if (::lstat(path.c_str(), &status) != 0) return errno == ENOENT; + exists = true; + return S_ISREG(status.st_mode); +#endif + } +} + +bool TimeUuidKey::operator==(const TimeUuidKey& rhs) const +{ + return ticks == rhs.ticks && tail == rhs.tail; +} + +bool TimeUuidKey::operator<(const TimeUuidKey& rhs) const +{ + // Cassandra orders equal-time UUIDs by signed bytes 8-15, not UUID text. + return ticks != rhs.ticks ? ticks < rhs.ticks : tail < rhs.tail; +} + +std::string directConversationId(const LLUUID& agent_id, const LLUUID& resident_id) +{ + return agent_id < resident_id + ? agent_id.asString() + "_" + resident_id.asString() + : resident_id.asString() + "_" + agent_id.asString(); +} + +bool parseCanonicalUuid(const std::string& text, LLUUID& id) +{ + if (text.size() != 36 || text != utf8str_tolower(text) || !LLUUID::validate(text)) + { + return false; + } + id.set(text, false); + return id.asString() == text; +} + +bool parseTimeUuid(const std::string& text, TimeUuidKey& key) +{ + LLUUID id; + if (!parseCanonicalUuid(text, id) || (id.mData[6] >> 4) != 1 || + (id.mData[8] & 0xc0) != 0x80) + { + return false; + } + + // UUIDv1 stores the 60-bit timestamp across three network-order fields. + const U64 time_low = (U64(id.mData[0]) << 24) | (U64(id.mData[1]) << 16) | + (U64(id.mData[2]) << 8) | U64(id.mData[3]); + const U64 time_mid = (U64(id.mData[4]) << 8) | U64(id.mData[5]); + const U64 time_high = (U64(id.mData[6] & 0x0f) << 8) | U64(id.mData[7]); + key.ticks = (time_high << 48) | (time_mid << 32) | time_low; + for (U32 pos = 0; pos < key.tail.size(); ++pos) + { + key.tail[pos] = static_cast<S8>(id.mData[pos + 8]); + } + return true; +} + +bool persistedDirectDialog(S32 dialog) +{ + switch (dialog) + { + case 0: case 3: case 4: case 20: case 22: case 26: case 38: + return true; + default: + return false; + } +} + +bool parseCreatedAt(const std::string& text, std::string& normalized) +{ + // Validate the complete wire shape before LLDate performs calendar conversion. + static const U32 DIGIT_POSITIONS[] = { + 0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18 + }; + if (text.size() < 19 || text[4] != '-' || text[7] != '-' || text[10] != 'T' || + text[13] != ':' || text[16] != ':') + { + return false; + } + for (U32 position : DIGIT_POSITIONS) + { + if (text[position] < '0' || text[position] > '9') return false; + } + const U32 year = std::stoi(text.substr(0, 4)); + const U32 month = std::stoi(text.substr(5, 2)); + const U32 day = std::stoi(text.substr(8, 2)); + const U32 hour = std::stoi(text.substr(11, 2)); + const U32 minute = std::stoi(text.substr(14, 2)); + const U32 second = std::stoi(text.substr(17, 2)); + static const U32 DAYS_PER_MONTH[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + if (!year || month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59) + return false; + U32 month_days = DAYS_PER_MONTH[month - 1]; + if (month == 2 && (year % 400 == 0 || (year % 4 == 0 && year % 100 != 0))) ++month_days; + if (day < 1 || day > month_days) return false; + + size_t position = 19; + if (position < text.size() && text[position] == '.') + { + const size_t fraction = ++position; + while (position < text.size() && text[position] >= '0' && text[position] <= '9') + ++position; + if (position == fraction) return false; + } + std::string parsed = text; + if (position == text.size()) + { + parsed += 'Z'; + } + else if (text[position] == 'Z') + { + if (position + 1 != text.size()) return false; + } + else if (text[position] == '+' || text[position] == '-') + { + if (position + 6 != text.size() || text[position + 3] != ':' || + !std::isdigit(static_cast<unsigned char>(text[position + 1])) || + !std::isdigit(static_cast<unsigned char>(text[position + 2])) || + !std::isdigit(static_cast<unsigned char>(text[position + 4])) || + !std::isdigit(static_cast<unsigned char>(text[position + 5]))) + { + return false; + } + const U32 hours = (text[position + 1] - '0') * 10 + text[position + 2] - '0'; + const U32 minutes = (text[position + 4] - '0') * 10 + text[position + 5] - '0'; + if (hours > 23 || minutes > 59) return false; + } + else + { + return false; + } + LLDate date; + if (!date.fromString(parsed)) + { + return false; + } + normalized = parsed; + return true; +} + +bool validateConversationList(const LLSD& value, const LLUUID& agent_id, + std::vector<ListEntry>& entries) +{ + entries.clear(); + if (!value.isArray() || value.size() > 10000) + { + return false; + } + std::set<LLUUID> residents; + std::set<std::string> conversations; + for (LLSD::array_const_iterator it = value.beginArray(); it != value.endArray(); ++it) + { + std::string type; + std::string conversation; + if (!it->isMap() || !llsdString(*it, "conversation_type", type) || + !llsdString(*it, "conversation_id", conversation) || conversation.empty() || + conversation.size() > 512 || !cleanText(conversation, 512) || + (type != "direct" && type != "group" && type != "adhoc")) + { + return false; + } + if (type != "direct") + { + continue; + } + std::string resident_text; + std::string token; + LLUUID resident; + TimeUuidKey token_key; + if (!llsdString(*it, "other_participant_id", resident_text) || + !llsdString(*it, "last_msg_id", token) || + !parseCanonicalUuid(resident_text, resident) || resident.isNull() || + resident == agent_id || + !parseTimeUuid(token, token_key) || + conversation != directConversationId(agent_id, resident) || + !residents.insert(resident).second || + !conversations.insert(conversation).second) + { + return false; + } + entries.push_back({resident, conversation, token}); + } + return true; +} + +bool validateHistoryPage(const LLSD& value, const LLUUID& agent_id, + const LLUUID& resident_id, + const std::string& conversation_id, + const std::string& requested_cursor, + U64 deleted_before_ticks, Page& page) +{ + page = Page(); + if (!value.isMap() || !value["conversation_id"].isString() || + value["conversation_id"].asString() != conversation_id || + !value["limit"].isInteger() || value["limit"].asInteger() != 100 || + !value["messages"].isArray() || value["messages"].size() > 100) + { + return false; + } + const LLSD& echoed = value["before_msg_id"]; + if ((requested_cursor.empty() && !echoed.isUndefined()) || + (!requested_cursor.empty() && + (!echoed.isString() || echoed.asString() != requested_cursor))) + { + return false; + } + + TimeUuidKey cursor_key; + if (!requested_cursor.empty() && !parseTimeUuid(requested_cursor, cursor_key)) + { + return false; + } + std::set<std::string> ids; + TimeUuidKey previous; + bool have_previous = false; + for (LLSD::array_const_iterator it = value["messages"].beginArray(); + it != value["messages"].endArray(); ++it) + { + Row row; + std::string from_text; + if (!it->isMap() || !llsdString(*it, "conversation_id", row.conversation_id) || + !llsdString(*it, "msg_id", row.msg_id) || + !llsdString(*it, "from_id", from_text) || + !llsdString(*it, "from_name", row.from_name) || + !llsdString(*it, "message", row.message) || + !(*it)["dialog"].isInteger() || + !llsdString(*it, "created_at", row.created_at) || + row.conversation_id != conversation_id || + !parseTimeUuid(row.msg_id, row.key) || !ids.insert(row.msg_id).second || + !parseCanonicalUuid(from_text, row.from_id) || + (row.from_id != agent_id && row.from_id != resident_id) || + !cleanText(row.from_name, 256) || !cleanText(row.message, 1024) || + !persistedDirectDialog(row.dialog = (*it)["dialog"].asInteger()) || + !parseCreatedAt(row.created_at, row.created_at) || + (have_previous && !(row.key < previous)) || + (!requested_cursor.empty() && !(row.key < cursor_key))) + { + return false; + } + have_previous = true; + previous = row.key; + page.next_cursor = row.msg_id; + if (row.key.ticks <= deleted_before_ticks) + { + page.cutoff_reached = true; + } + else if (!page.cutoff_reached) + { + page.rows.push_back(row); + } + } + page.terminal = value["messages"].size() == 0 || page.cutoff_reached; + if (page.terminal) + { + page.next_cursor.clear(); + } + return true; +} + +std::string quoteCsv(const std::string& value) +{ + if (value.find_first_of(",\"\r\n") == std::string::npos) + { + return value; + } + std::string result("\""); + for (char ch : value) + { + result.push_back(ch); + if (ch == '"') + { + result.push_back('"'); + } + } + return result + '"'; +} + +void writeCsvRow(std::ostream& output, const Row& row) +{ + output << quoteCsv(row.conversation_id) << ',' << quoteCsv(row.msg_id) << ',' + << quoteCsv(row.from_id.asString()) << ',' << quoteCsv(row.from_name) << ',' + << quoteCsv(row.message) << ',' << row.dialog << ',' + << quoteCsv(row.created_at) << '\n'; +} + +bool archiveStamp(const std::string& path, U64& file_size, S64& file_mtime) +{ + bool exists = false; + if (!inspectRegularFile(path, exists) || !exists) return false; + std::error_code error; + const std::filesystem::path native_path = fsyspath(path); + const uintmax_t size = std::filesystem::file_size(native_path, error); + if (error || size > std::numeric_limits<U64>::max()) return false; + const std::filesystem::file_time_type write_time = + std::filesystem::last_write_time(native_path, error); + if (error) return false; + file_size = static_cast<U64>(size); + file_mtime = static_cast<S64>(write_time.time_since_epoch().count()); + return true; +} + +bool scanArchive(const std::string& path, const LLUUID& agent_id, + const LLUUID& resident_id, U64 deleted_before_ticks, + U32 display_cap, ArchiveScan& scan) +{ + scan = ArchiveScan(); + bool exists = false; + if (!inspectRegularFile(path, exists)) + { + scan.state = ARCHIVE_FAILED; + return false; + } + if (!exists) + { + scan.state = ARCHIVE_ABSENT; + return true; + } + if (!archiveStamp(path, scan.file_size, scan.file_mtime)) + { + scan.state = ARCHIVE_FAILED; + return false; + } + llifstream input(path.c_str(), std::ios::binary); + if (!input.is_open()) + { + scan.state = ARCHIVE_FAILED; + return false; + } + + std::vector<std::string> fields; + bool torn = false; + bool present = false; + if (!readRecord(input, fields, torn, present) || !present || torn || !headerMatches(fields)) + { + scan.state = ARCHIVE_CORRUPT; + return false; + } + scan.valid_prefix_bytes = input.tellg(); + TimeUuidKey previous; + bool have_previous = false; + std::deque<Row> newest; + for (;;) + { + if (!readRecord(input, fields, torn, present)) + { + scan.state = ARCHIVE_CORRUPT; + return false; + } + if (!present) + { + scan.state = torn ? ARCHIVE_TORN : ARCHIVE_VALID; + break; + } + if (torn) + { + scan.state = ARCHIVE_TORN; + break; + } + Row row; + if (!rowFromFields(fields, agent_id, resident_id, row) || + (have_previous && !(previous < row.key))) + { + scan.state = ARCHIVE_CORRUPT; + return false; + } + have_previous = true; + previous = row.key; + const std::streamoff position = input.tellg(); + scan.valid_prefix_bytes = position >= 0 ? position : scan.file_size; + if (row.key.ticks <= deleted_before_ticks) + { + continue; + } + if (!scan.has_oldest) + { + scan.has_oldest = true; + scan.oldest = row.key; + } + scan.newest = row.key; + ++scan.row_count; + if (display_cap) + { + newest.push_back(row); + if (newest.size() > display_cap) + { + newest.pop_front(); + } + } + } + scan.display_rows.assign(newest.begin(), newest.end()); + return scan.state == ARCHIVE_VALID; +} +} diff --git a/indra/newview/llchatservicehistorycore.h b/indra/newview/llchatservicehistorycore.h new file mode 100644 index 0000000000..9d2301ebf5 --- /dev/null +++ b/indra/newview/llchatservicehistorycore.h @@ -0,0 +1,111 @@ +/** + * @file llchatservicehistorycore.h + * @brief Strict ChatService wire, TimeUUID, and CSV primitives. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * $/LicenseInfo$ + */ +#ifndef LL_LLCHATSERVICEHISTORYCORE_H +#define LL_LLCHATSERVICEHISTORYCORE_H + +#include "llsd.h" +#include "lluuid.h" + +#include <array> +#include <deque> +#include <iosfwd> +#include <string> +#include <vector> + +namespace LLChatServiceHistoryCore +{ + extern const char* const CSV_HEADER; + extern const char* const INDEX_HEADER; + + struct TimeUuidKey + { + U64 ticks = 0; + std::array<S8, 8> tail{}; + + bool operator==(const TimeUuidKey& rhs) const; + bool operator<(const TimeUuidKey& rhs) const; + bool operator>(const TimeUuidKey& rhs) const { return rhs < *this; } + bool operator<=(const TimeUuidKey& rhs) const { return !(rhs < *this); } + bool operator>=(const TimeUuidKey& rhs) const { return !(*this < rhs); } + }; + + struct Row + { + std::string conversation_id; + std::string msg_id; + LLUUID from_id; + std::string from_name; + std::string message; + S32 dialog = 0; + std::string created_at; + TimeUuidKey key; + }; + + struct ListEntry + { + LLUUID resident_id; + std::string conversation_id; + std::string last_msg_id; + }; + + struct Page + { + std::vector<Row> rows; + std::string next_cursor; + bool terminal = false; + bool cutoff_reached = false; + }; + + enum EArchiveState + { + ARCHIVE_ABSENT, + ARCHIVE_VALID, + ARCHIVE_TORN, + ARCHIVE_CORRUPT, + ARCHIVE_FAILED + }; + + struct ArchiveScan + { + EArchiveState state = ARCHIVE_ABSENT; + U32 row_count = 0; + bool has_oldest = false; + TimeUuidKey oldest; + TimeUuidKey newest; + U64 file_size = 0; + S64 file_mtime = 0; + std::streamoff valid_prefix_bytes = 0; + std::vector<Row> display_rows; + }; + + std::string directConversationId(const LLUUID& agent_id, const LLUUID& resident_id); + bool parseCanonicalUuid(const std::string& text, LLUUID& id); + bool parseTimeUuid(const std::string& text, TimeUuidKey& key); + bool persistedDirectDialog(S32 dialog); + bool parseCreatedAt(const std::string& text, std::string& normalized); + + bool validateConversationList(const LLSD& value, const LLUUID& agent_id, + std::vector<ListEntry>& entries); + bool validateHistoryPage(const LLSD& value, const LLUUID& agent_id, + const LLUUID& resident_id, + const std::string& conversation_id, + const std::string& requested_cursor, + U64 deleted_before_ticks, Page& page); + + std::string quoteCsv(const std::string& value); + void writeCsvRow(std::ostream& output, const Row& row); + bool scanArchive(const std::string& path, const LLUUID& agent_id, + const LLUUID& resident_id, U64 deleted_before_ticks, + U32 display_cap, ArchiveScan& scan); + + bool archiveStamp(const std::string& path, U64& file_size, S64& file_mtime); +} + +#endif diff --git a/indra/newview/llconversationlog.cpp b/indra/newview/llconversationlog.cpp index bfcc2e4182..c9ae6ccdce 100644 --- a/indra/newview/llconversationlog.cpp +++ b/indra/newview/llconversationlog.cpp @@ -614,7 +614,8 @@ bool LLConversationLog::loadFromFile(const std::string& filename) // CHUI-325 // The conversation log should be capped to the last 30 days. Conversations with the last utterance // being over 30 days old should be purged from the conversation log text file on login. - if (conversation.isOlderThan(CONVERSATION_LIFETIME)) + if (conversation.getConversationType() != LLIMModel::LLIMSession::P2P_SESSION && + conversation.isOlderThan(CONVERSATION_LIFETIME)) { purge_required = true; continue; diff --git a/indra/newview/llconversationloglist.cpp b/indra/newview/llconversationloglist.cpp index e9c6df3ed8..c07f7cdfcb 100644 --- a/indra/newview/llconversationloglist.cpp +++ b/indra/newview/llconversationloglist.cpp @@ -26,6 +26,7 @@ #include "llviewerprecompiledheaders.h" #include "llavataractions.h" +#include "llchatservicehistory.h" #include "llagent.h" #include "llfloaterreg.h" #include "llfloaterconversationpreview.h" @@ -364,7 +365,8 @@ void LLConversationLogList::onCustomAction(const LLSD& userdata) } else if ("chat_history" == command_name) { - LLFloaterReg::showInstance("preview_conversation", selected_conversation_session_id, true); + if (!LLChatServiceHistory::historySuppressed()) + LLFloaterReg::showInstance("preview_conversation", selected_conversation_session_id, true); } else if ("offer_teleport" == command_name) { @@ -438,7 +440,7 @@ bool LLConversationLogList::isActionEnabled(const LLSD& userdata) } else if ("can_view_chat_history" == command_name) { - return true; + return !LLChatServiceHistory::historySuppressed(); } else if ("can_call" == command_name) { diff --git a/indra/newview/llfloaterconversationlog.cpp b/indra/newview/llfloaterconversationlog.cpp index 40e2d2bf1e..876c351ab0 100644 --- a/indra/newview/llfloaterconversationlog.cpp +++ b/indra/newview/llfloaterconversationlog.cpp @@ -26,6 +26,7 @@ #include "llviewerprecompiledheaders.h" #include "llconversationloglist.h" +#include "llchatservicehistory.h" #include "llfiltereditor.h" #include "llfloaterconversationlog.h" #include "llfloaterreg.h" @@ -106,12 +107,15 @@ void LLFloaterConversationLog::onCustomAction (const LLSD& userdata) } else if ("view_nearby_chat_history" == command_name) { - LLFloaterReg::showInstance("preview_conversation", LLSD(LLUUID::null), true); + if (!LLChatServiceHistory::historySuppressed()) + LLFloaterReg::showInstance("preview_conversation", LLSD(LLUUID::null), true); } } bool LLFloaterConversationLog::isActionEnabled(const LLSD& userdata) { + if (userdata.asString() == "view_nearby_chat_history") + return !LLChatServiceHistory::historySuppressed(); return true; } @@ -140,4 +144,3 @@ bool LLFloaterConversationLog::isActionChecked(const LLSD& userdata) return false; } - diff --git a/indra/newview/llfloaterconversationpreview.cpp b/indra/newview/llfloaterconversationpreview.cpp index 6f5d81eda3..6b871e3bb2 100644 --- a/indra/newview/llfloaterconversationpreview.cpp +++ b/indra/newview/llfloaterconversationpreview.cpp @@ -26,6 +26,7 @@ #include "llviewerprecompiledheaders.h" #include "llavatarnamecache.h" +#include "llchatservicehistory.h" #include "llconversationlog.h" #include "llfloaterconversationpreview.h" #include "llimview.h" @@ -34,15 +35,21 @@ #include "llspinctrl.h" #include "lltrans.h" #include "llnotificationsutil.h" +#include "llloadingindicator.h" +#include "workqueue.h" + +#include <limits> const std::string LL_FCP_COMPLETE_NAME("complete_name"); const std::string LL_FCP_ACCOUNT_NAME("user_name"); +const std::string LL_FCP_PARTICIPANT_ID("participant_id"); const S32 CONVERSATION_HISTORY_PAGE_SIZE = 100; LLFloaterConversationPreview::LLFloaterConversationPreview(const LLSD& session_id) : LLFloater(session_id), mChatHistory(NULL), mSessionID(session_id.asUUID()), + mParticipantID(session_id[LL_FCP_PARTICIPANT_ID].asUUID()), mCurrentPage(0), mPageSize(CONVERSATION_HISTORY_PAGE_SIZE), mAccountName(session_id[LL_FCP_ACCOUNT_NAME]), @@ -52,12 +59,23 @@ LLFloaterConversationPreview::LLFloaterConversationPreview(const LLSD& session_i mMessages(NULL), mHistoryThreadsBusy(false), mIsGroup(false), + mIsP2P(false), + mServiceLocalLoading(false), + mServiceReloadPending(false), + mLoadingIndicatorVisible(false), + mServiceNameReloaded(false), + mServicePresentationAllowed(false), + mServiceToken(0), + mServiceAppliedSerial(std::numeric_limits<U32>::max()), mOpened(false) { } LLFloaterConversationPreview::~LLFloaterConversationPreview() { + mHistoryContentConnection.disconnect(); + mServiceSnapshotConnection.disconnect(); + delete mMessages; } bool LLFloaterConversationPreview::postBuild() @@ -68,7 +86,14 @@ bool LLFloaterConversationPreview::postBuild() std::string name; std::string file; - if (mAccountName != "") + if (mParticipantID.notNull()) + { + // A direct-conversation key stays direct while its shared name is unresolved. + mIsP2P = true; + name = mCompleteName; + file = mAccountName; + } + else if (mAccountName != "") { name = mCompleteName; file = mAccountName; @@ -78,6 +103,8 @@ bool LLFloaterConversationPreview::postBuild() name = conv->getConversationName(); file = conv->getHistoryFileName(); mIsGroup = (LLIMModel::LLIMSession::GROUP_SESSION == conv->getConversationType()); + mIsP2P = (LLIMModel::LLIMSession::P2P_SESSION == conv->getConversationType()); + if (mIsP2P) mParticipantID = conv->getParticipantID(); } else { @@ -93,6 +120,8 @@ bool LLFloaterConversationPreview::postBuild() args["[NAME]"] = name; std::string title = getString("Title", args); setTitle(title); + getChild<LLTextBox>("chat_service_loading_text")->setValue( + LLTrans::getString("loading_chat_logs")); return LLFloater::postBuild(); } @@ -103,18 +132,23 @@ void LLFloaterConversationPreview::setPages(std::list<LLSD>* messages, const std { // additional protection to avoid changes of mMessages in setPages() LLMutexLock lock(&mMutex); + const S32 old_last_page = mMessages && !mMessages->empty() + ? (static_cast<S32>(mMessages->size()) - 1) / mPageSize : 0; + const S32 distance_from_newest = llmax(0, old_last_page - mCurrentPage); if (mMessages) { delete mMessages; // Clean up temporary message list with "Loading..." text } mMessages = messages; - mCurrentPage = (mMessages->size() ? (static_cast<int>(mMessages->size()) - 1) / mPageSize : 0); + const S32 last_page = mMessages->empty() + ? 0 : (static_cast<S32>(mMessages->size()) - 1) / mPageSize; + mCurrentPage = llmax(0, last_page - distance_from_newest); mPageSpinner->setEnabled(true); - mPageSpinner->setMaxValue((F32)(mCurrentPage+1)); + mPageSpinner->setMaxValue((F32)(last_page+1)); mPageSpinner->set((F32)(mCurrentPage+1)); - std::string total_page_num = llformat("/ %d", mCurrentPage+1); + std::string total_page_num = llformat("/ %d", last_page+1); getChild<LLTextBox>("page_num_label")->setValue(total_page_num); mShowHistory = true; } @@ -127,6 +161,17 @@ void LLFloaterConversationPreview::setPages(std::list<LLSD>* messages, const std void LLFloaterConversationPreview::draw() { + const bool loading = mIsP2P && + (mServiceLocalLoading || + LLChatServiceHistory::getSnapshot(mParticipantID).service_work_active); + if (loading != mLoadingIndicatorVisible) + { + mLoadingIndicatorVisible = loading; + getChildView("chat_service_loading")->setVisible(loading); + LLLoadingIndicator* indicator = + getChild<LLLoadingIndicator>("chat_service_loading_wheel"); + if (loading) indicator->start(); else indicator->stop(); + } if(mShowHistory) { showHistory(); @@ -142,6 +187,51 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) return; } mOpened = true; + ++mServiceToken; + mPageSpinner = getChild<LLSpinCtrl>("history_page_spin"); + mPageSpinner->setCommitCallback( + boost::bind(&LLFloaterConversationPreview::onMoreHistoryBtnClick, this)); + mPageSpinner->setMinValue(1); + mPageSpinner->set(1); + mPageSpinner->setEnabled(false); + + if (mIsP2P) + { + LLHandle<LLFloaterConversationPreview> handle = + getDerivedHandle<LLFloaterConversationPreview>(); + mServiceSnapshotConnection = LLChatServiceHistory::setSnapshotChanged( + [handle, participant = mParticipantID]( + const LLUUID& changed, const LLChatServiceHistory::Snapshot& snapshot) + { + LLFloaterConversationPreview* floater = handle.get(); + if (floater && changed == participant) + floater->onServiceSnapshot(snapshot); + }); + const LLChatServiceHistory::Snapshot snapshot = + LLChatServiceHistory::getSnapshot(mParticipantID); + mServicePresentationAllowed = snapshot.service_presentation_allowed; + mServiceAppliedSerial = snapshot.archive_serial; + LLChatServiceHistory::prioritizeResident(mParticipantID); + onServiceSnapshot(snapshot); + startServiceLoad(); + return; + } + LLHandle<LLFloaterConversationPreview> handle = + getDerivedHandle<LLFloaterConversationPreview>(); + mHistoryContentConnection = LLLogChat::getInstance()->setSaveHistorySignal([handle]() + { + LLFloaterConversationPreview* floater = handle.get(); + if (floater && floater->mOpened && !LLChatServiceHistory::historySuppressed()) + floater->startLegacyLoad(); + }); + startLegacyLoad(); +} + +void LLFloaterConversationPreview::startLegacyLoad() +{ + if (!mOpened || mIsP2P) return; + if (LLChatServiceHistory::historySuppressed()) return; + mHistoryContentConnection.disconnect(); if (!LLLogChat::getInstance()->historyThreadsFinished(mSessionID)) { LLNotificationsUtil::add("ChatHistoryIsBusyAlert"); @@ -149,6 +239,7 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) closeFloater(); return; } + const U64 load_token = ++mServiceToken; LLSD load_params; load_params["load_all_history"] = true; load_params["cut_off_todays_date"] = false; @@ -156,18 +247,13 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) // The temporary message list with "Loading..." text // Will be deleted upon loading completion in setPages() method + delete mMessages; mMessages = new std::list<LLSD>(); LLSD loading; loading[LL_IM_TEXT] = LLTrans::getString("loading_chat_logs"); mMessages->push_back(loading); - mPageSpinner = getChild<LLSpinCtrl>("history_page_spin"); - mPageSpinner->setCommitCallback(boost::bind(&LLFloaterConversationPreview::onMoreHistoryBtnClick, this)); - mPageSpinner->setMinValue(1); - mPageSpinner->set(1); - mPageSpinner->setEnabled(false); - // The actual message list to load from file // Will be deleted in a separate thread LLDeleteHistoryThread not to freeze UI // LLDeleteHistoryThread is started in destructor @@ -177,7 +263,29 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) log_chat_inst->cleanupHistoryThreads(); LLLoadHistoryThread* loadThread = new LLLoadHistoryThread(mChatHistoryFileName, messages, load_params); - loadThread->setLoadEndSignal(boost::bind(&LLFloaterConversationPreview::setPages, this, _1, _2)); + LLHandle<LLFloaterConversationPreview> handle = + getDerivedHandle<LLFloaterConversationPreview>(); + LL::WorkQueue::ptr_t main = LL::WorkQueue::getInstance("mainloop"); + loadThread->setLoadEndSignal( + [handle, load_token, main](std::list<LLSD>* loaded, const std::string& file_name) + { + // Copy before the delete thread reclaims loader-owned data, then apply on main. + std::shared_ptr<std::list<LLSD>> copy = + std::make_shared<std::list<LLSD>>(*loaded); + if (main) + { + main->post([handle, load_token, copy, file_name]() + { + LLFloaterConversationPreview* floater = handle.get(); + if (floater && floater->mOpened && + load_token == floater->mServiceToken && + !LLChatServiceHistory::historySuppressed()) + { + floater->setPages(new std::list<LLSD>(*copy), file_name); + } + }); + } + }); loadThread->start(); log_chat_inst->addLoadHistoryThread(mSessionID, loadThread); @@ -190,6 +298,12 @@ void LLFloaterConversationPreview::onOpen(const LLSD& key) void LLFloaterConversationPreview::onClose(bool app_quitting) { mOpened = false; + ++mServiceToken; + mServiceLocalLoading = false; + mServiceReloadPending = false; + mHistoryContentConnection.disconnect(); + mServiceSnapshotConnection.disconnect(); + if (mIsP2P) return; if (!mHistoryThreadsBusy) { LLDeleteHistoryThread* deleteThread = LLLogChat::getInstance()->getDeleteHistoryThread(mSessionID); @@ -200,16 +314,116 @@ void LLFloaterConversationPreview::onClose(bool app_quitting) } } +void LLFloaterConversationPreview::invalidateHistory() +{ + ++mServiceToken; + mServiceLocalLoading = false; + mServiceReloadPending = false; + if (mMessages) mMessages->clear(); + if (mChatHistory) mChatHistory->clear(); +} + +void LLFloaterConversationPreview::startServiceLoad() +{ + if (!mOpened || mServiceLocalLoading || mParticipantID.isNull()) return; + const U64 token = ++mServiceToken; + mServiceLocalLoading = true; + LLHandle<LLFloaterConversationPreview> handle = + getDerivedHandle<LLFloaterConversationPreview>(); + if (!LLChatServiceHistory::loadStitchedHistory( + mParticipantID, mChatHistoryFileName, 10000, + [handle, token](const LLChatServiceHistory::HistoryResult& result) + { + if (LLFloaterConversationPreview* floater = handle.get()) + floater->onServiceLoaded(token, result); + })) + { + mServiceLocalLoading = false; + } +} + +void LLFloaterConversationPreview::onServiceLoaded( + U64 token, const LLChatServiceHistory::HistoryResult& result) +{ + if (!mOpened || token != mServiceToken) return; + mServiceLocalLoading = false; + const LLChatServiceHistory::Snapshot snapshot = + LLChatServiceHistory::getSnapshot(mParticipantID); + if (result.account_epoch != LLChatServiceHistory::accountEpoch() || + LLChatServiceHistory::historySuppressed()) + { + return; + } + const bool reload_pending = mServiceReloadPending; + mServiceReloadPending = false; + if (result.archive_serial != snapshot.archive_serial) + { + startServiceLoad(); + return; + } + if (result.included_service != snapshot.service_presentation_allowed) + { + startServiceLoad(); + return; + } + mServiceAppliedSerial = result.archive_serial; + std::list<LLSD> merged = LLChatServiceHistory::mergeHeadPreview( + result.messages, snapshot, 10000); + setPages(new std::list<LLSD>(merged), mChatHistoryFileName); + if (reload_pending) + { + startServiceLoad(); + } +} + +void LLFloaterConversationPreview::onServiceSnapshot( + const LLChatServiceHistory::Snapshot& snapshot) +{ + if (!mOpened) return; + const bool presentation_changed = + snapshot.service_presentation_allowed != mServicePresentationAllowed; + mServicePresentationAllowed = snapshot.service_presentation_allowed; + if (presentation_changed && mServiceLocalLoading) + mServiceReloadPending = true; + if (presentation_changed && !snapshot.service_presentation_allowed && mMessages) + { + std::list<LLSD>* legacy_only = new std::list<LLSD>(); + for (const LLSD& message : *mMessages) + { + if (!message["chat_service_msg_id"].isString()) legacy_only->push_back(message); + } + setPages(legacy_only, mChatHistoryFileName); + } + if (mChatHistoryFileName.empty() && !mServiceNameReloaded && + snapshot.metadata_resolved) + { + mChatHistoryFileName = LLCacheName::buildUsername(snapshot.metadata.getUserName()); + mServiceNameReloaded = true; + if (mServiceLocalLoading) mServiceReloadPending = true; + else startServiceLoad(); + } + if (snapshot.service_presentation_allowed && !snapshot.head_preview.empty()) + { + std::list<LLSD> merged = LLChatServiceHistory::mergeHeadPreview( + mMessages ? *mMessages : std::list<LLSD>(), snapshot, 10000); + setPages(new std::list<LLSD>(merged), mChatHistoryFileName); + } + if (!mServiceLocalLoading && + (presentation_changed || snapshot.archive_serial != mServiceAppliedSerial)) + { + startServiceLoad(); + } +} + void LLFloaterConversationPreview::showHistory() { // additional protection to avoid changes of mMessages in setPages LLMutexLock lock(&mMutex); + mChatHistory->clear(); if(mMessages == NULL || !mMessages->size() || mCurrentPage * mPageSize >= mMessages->size()) { return; } - - mChatHistory->clear(); std::ostringstream message; std::list<LLSD>::const_iterator iter = mMessages->begin(); std::advance(iter, mCurrentPage * mPageSize); diff --git a/indra/newview/llfloaterconversationpreview.h b/indra/newview/llfloaterconversationpreview.h index 563592fb20..6b02d60f2d 100644 --- a/indra/newview/llfloaterconversationpreview.h +++ b/indra/newview/llfloaterconversationpreview.h @@ -27,10 +27,12 @@ #define LLFLOATERCONVERSATIONPREVIEW_H_ #include "llchathistory.h" +#include "llchatservicehistory.h" #include "llfloater.h" extern const std::string LL_FCP_COMPLETE_NAME; //"complete_name" extern const std::string LL_FCP_ACCOUNT_NAME; //"user_name" +extern const std::string LL_FCP_PARTICIPANT_ID; //"participant_id" class LLSpinCtrl; @@ -47,15 +49,21 @@ public: void draw() override; void onOpen(const LLSD& key) override; void onClose(bool app_quitting) override; + void invalidateHistory(); private: void onMoreHistoryBtnClick(); void showHistory(); + void startLegacyLoad(); + void startServiceLoad(); + void onServiceLoaded(U64 token, const LLChatServiceHistory::HistoryResult& result); + void onServiceSnapshot(const LLChatServiceHistory::Snapshot& snapshot); LLMutex mMutex; LLSpinCtrl* mPageSpinner; LLChatHistory* mChatHistory; LLUUID mSessionID; + LLUUID mParticipantID; int mCurrentPage; int mPageSize; @@ -67,6 +75,16 @@ private: bool mHistoryThreadsBusy; bool mOpened; bool mIsGroup; + bool mIsP2P; + bool mServiceLocalLoading; + bool mServiceReloadPending; + bool mLoadingIndicatorVisible; + bool mServiceNameReloaded; + bool mServicePresentationAllowed; + U64 mServiceToken; + U32 mServiceAppliedSerial; + boost::signals2::connection mHistoryContentConnection; + boost::signals2::connection mServiceSnapshotConnection; }; #endif /* LLFLOATERCONVERSATIONPREVIEW_H_ */ diff --git a/indra/newview/llfloaterimcontainer.cpp b/indra/newview/llfloaterimcontainer.cpp index f4a22c6728..81a51617bb 100644 --- a/indra/newview/llfloaterimcontainer.cpp +++ b/indra/newview/llfloaterimcontainer.cpp @@ -39,6 +39,7 @@ #include "llavatariconctrl.h" #include "llavatarnamecache.h" #include "llcallbacklist.h" +#include "llchatservicehistory.h" #include "lldonotdisturbnotificationstorage.h" #include "llgroupactions.h" #include "llgroupiconctrl.h" @@ -1395,6 +1396,7 @@ void LLFloaterIMContainer::doToSelectedConversation(const std::string& command, } else if("chat_history" == command) { + if (LLChatServiceHistory::historySuppressed()) return; if (selectedIDS.size() > 0) { if(conversationItem->getType() == LLConversationItem::CONV_SESSION_GROUP) @@ -1430,7 +1432,8 @@ void LLFloaterIMContainer::doToSelectedConversation(const std::string& command, { if("chat_history" == command) { - LLFloaterReg::showInstance("preview_conversation", LLSD(LLUUID::null), true); + if (!LLChatServiceHistory::historySuppressed()) + LLFloaterReg::showInstance("preview_conversation", LLSD(LLUUID::null), true); } } } @@ -1505,6 +1508,7 @@ bool LLFloaterIMContainer::enableContextMenuItem(const LLSD& userdata) //Enable Chat history item for ad-hoc and group conversations if ("can_chat_history" == item && uuids.size() > 0) { + if (LLChatServiceHistory::historySuppressed()) return false; //Disable menu item if selected participant is user agent if(uuids.front() != gAgentID) { @@ -1524,7 +1528,9 @@ bool LLFloaterIMContainer::enableContextMenuItem(const LLSD& userdata) else { bool is_group = (getCurSelectedViewModelItem()->getType() == LLConversationItem::CONV_SESSION_GROUP); - return LLLogChat::isTranscriptExist(uuids.front(),is_group); + return !LLChatServiceHistory::historySuppressed() && + (LLLogChat::isTranscriptExist(uuids.front(),is_group) || + (!is_group && LLChatServiceHistory::localHistoryExists(uuids.front()))); } } } @@ -1556,7 +1562,9 @@ bool LLFloaterIMContainer::enableContextMenuItem(const std::string& item, uuid_v if ("can_chat_history" == item && is_single_select) { - return LLLogChat::isTranscriptExist(uuids.front(),false); + return !LLChatServiceHistory::historySuppressed() && + (LLLogChat::isTranscriptExist(uuids.front(),false) || + LLChatServiceHistory::localHistoryExists(uuids.front())); } // Handle options that are applicable to all including the user agent diff --git a/indra/newview/llfloaterimsession.cpp b/indra/newview/llfloaterimsession.cpp index 73bce6f2fd..6db370916e 100644 --- a/indra/newview/llfloaterimsession.cpp +++ b/indra/newview/llfloaterimsession.cpp @@ -51,6 +51,8 @@ #include "llsyswellwindow.h" #include "lltrans.h" #include "llchathistory.h" +#include "llchatservicehistory.h" +#include "llloadingindicator.h" #include "llnotifications.h" #include "llviewerregion.h" #include "llviewerwindow.h" @@ -81,6 +83,7 @@ LLFloaterIMSession::LLFloaterIMSession(const LLUUID& session_id) mTypingTimeoutTimer(), mPositioned(false), mSessionInitialized(false), + mChatServiceLoadingVisible(false), mMeTypingTimer(), mOtherTypingTimer() { @@ -374,6 +377,8 @@ bool LLFloaterIMSession::postBuild() //see LLFloaterIMPanel for how it is done (IB) initIMFloater(); + getChild<LLTextBox>("chat_service_loading_text")->setValue( + LLTrans::getString("loading_chat_logs")); return result; } @@ -713,6 +718,8 @@ void LLFloaterIMSession::setVisible(bool visible) if (visible && isInVisibleChain()) { + // Visibility updates presentation state; session creation and live delivery + // own service refresh priority. sIMFloaterShowedSignal(mSessionID); updateMessages(); } @@ -1117,6 +1124,20 @@ void LLFloaterIMSession::processSessionUpdate(const LLSD& session_update) // virtual void LLFloaterIMSession::draw() { + const bool loading = mSession && mIsP2PChat && + gSavedPerAccountSettings.getBOOL("LogShowHistory") && + (mSession->isChatHistoryLoading() || + LLChatServiceHistory::getSnapshot( + mSession->mOtherParticipantID).service_work_active); + if (loading != mChatServiceLoadingVisible) + { + mChatServiceLoadingVisible = loading; + getChildView("chat_service_loading")->setVisible(loading); + LLLoadingIndicator* indicator = + getChild<LLLoadingIndicator>("chat_service_loading_wheel"); + if (loading) indicator->start(); else indicator->stop(); + } + // add people who were added via dropPerson() if (!mPendingParticipants.empty()) { diff --git a/indra/newview/llfloaterimsession.h b/indra/newview/llfloaterimsession.h index aa03fc4e62..d90354c718 100644 --- a/indra/newview/llfloaterimsession.h +++ b/indra/newview/llfloaterimsession.h @@ -189,6 +189,7 @@ private: LLFrameTimer mOtherTypingTimer; bool mSessionInitialized; + bool mChatServiceLoadingVisible; LLSD mQueuedMsgsForInit; uuid_vec_t mInvitedParticipants; diff --git a/indra/newview/llfloaterimsessiontab.cpp b/indra/newview/llfloaterimsessiontab.cpp index b70d21e2c5..2e044edc46 100644 --- a/indra/newview/llfloaterimsessiontab.cpp +++ b/indra/newview/llfloaterimsessiontab.cpp @@ -1052,6 +1052,11 @@ void LLFloaterIMSessionTab::reshapeChatLayoutPanel() // static void LLFloaterIMSessionTab::processChatHistoryStyleUpdate(bool clean_messages/* = false*/) { + if (clean_messages) + { + // Model-owned direct history is reloaded once, including sessions without a floater. + LLIMModel::instance().reloadDirectHistories(); + } LLFloaterReg::const_instance_list_t& inst_list = LLFloaterReg::getFloaterList("impanel"); for (LLFloaterReg::const_instance_list_t::const_iterator iter = inst_list.begin(); iter != inst_list.end(); ++iter) @@ -1059,7 +1064,11 @@ void LLFloaterIMSessionTab::processChatHistoryStyleUpdate(bool clean_messages/* LLFloaterIMSession* floater = dynamic_cast<LLFloaterIMSession*>(*iter); if (floater) { - floater->reloadMessages(clean_messages); + LLIMModel::LLIMSession* session = + LLIMModel::instance().findIMSession(floater->mSessionID); + const bool reload_model = clean_messages && + (!session || !session->isP2P()); + floater->reloadMessages(reload_model); } } diff --git a/indra/newview/llfloaterpreference.cpp b/indra/newview/llfloaterpreference.cpp index a0db801dd2..5251c9fb58 100644 --- a/indra/newview/llfloaterpreference.cpp +++ b/indra/newview/llfloaterpreference.cpp @@ -33,6 +33,7 @@ #include "llviewerprecompiledheaders.h" #include "llfloaterpreference.h" +#include "llchatservicehistory.h" #include "message.h" #include "llfloaterautoreplacesettings.h" @@ -542,7 +543,8 @@ bool LLFloaterPreference::postBuild() void LLFloaterPreference::updateDeleteTranscriptsButton() { - mDeleteTranscriptsBtn->setEnabled(LLLogChat::transcriptFilesExist()); + mDeleteTranscriptsBtn->setEnabled(!mDeleteTranscriptsPending && + (LLLogChat::transcriptFilesExist() || LLChatServiceHistory::localHistoryExists())); } void LLFloaterPreference::onDoNotDisturbResponseChanged() @@ -1918,17 +1920,28 @@ void LLFloaterPreference::onDeleteTranscriptsResponse(const LLSD& notification, { if (0 == LLNotificationsUtil::getSelectedOption(notification, response)) { - LLLogChat::deleteTranscripts(); + LLHandle<LLFloaterPreference> handle = getDerivedHandle<LLFloaterPreference>(); + mDeleteTranscriptsPending = true; updateDeleteTranscriptsButton(); + if (!LLChatServiceHistory::deleteTranscriptsAsync( + [handle](bool) + { + if (LLFloaterPreference* floater = handle.get()) + { + floater->mDeleteTranscriptsPending = false; + floater->updateDeleteTranscriptsButton(); + } + })) + { + mDeleteTranscriptsPending = false; + updateDeleteTranscriptsButton(); + } } } void LLFloaterPreference::onLogChatHistorySaved() { - if (!mDeleteTranscriptsBtn->getEnabled()) - { - mDeleteTranscriptsBtn->setEnabled(true); - } + updateDeleteTranscriptsButton(); } void LLFloaterPreference::updateClickActionControls() diff --git a/indra/newview/llfloaterpreference.h b/indra/newview/llfloaterpreference.h index aa958459c9..3d3f081462 100644 --- a/indra/newview/llfloaterpreference.h +++ b/indra/newview/llfloaterpreference.h @@ -235,6 +235,7 @@ private: LLScrollListCtrl* mEnabledPopups = nullptr; LLScrollListCtrl* mDisabledPopups = nullptr; LLButton* mDeleteTranscriptsBtn = nullptr; + bool mDeleteTranscriptsPending = false; LLButton* mEnablePopupBtn = nullptr; LLButton* mDisablePopupBtn = nullptr; LLComboBox* mTimeFormatCombobox = nullptr; diff --git a/indra/newview/llimview.cpp b/indra/newview/llimview.cpp index 252eb32e7c..2823b6d256 100644 --- a/indra/newview/llimview.cpp +++ b/indra/newview/llimview.cpp @@ -29,6 +29,7 @@ #include "llimview.h" #include "llavatarnamecache.h" // IDEVO +#include "llchatservicehistory.h" #include "llavataractions.h" #include "llfloaterconversationlog.h" #include "llfloaterreg.h" @@ -85,6 +86,9 @@ const static std::string XL8_START_TAG(" ("); const static std::string XL8_END_TAG(")"); const S32 XL8_PADDING = 3; // XL8_START_TAG.size() + XL8_END_TAG.size() +// Tokens are process-unique so a stale completion cannot match a recreated P2P session. +static U64 sChatHistoryLoadToken = 0; + /** Timeout of outgoing session initialization (in seconds) */ const static U32 SESSION_INITIALIZATION_TIMEOUT = 30; @@ -793,7 +797,6 @@ LLIMModel::LLIMSession::LLIMSession(const LLUUID& session_id, } buildHistoryFileName(); - loadHistory(); // Localizing name of ad-hoc session. STORM-153 // Changing name should happen here- after the history file was created, so that @@ -994,6 +997,7 @@ LLIMModel::LLIMSession::~LLIMSession() mSpeakers = NULL; mVoiceChannelStateChangeConnection.disconnect(); + mChatServiceSnapshotConnection.disconnect(); // HAVE to do this here -- if it happens in the LLVoiceChannel destructor it will call the wrong version (since the object's partially deconstructed at that point). mVoiceChannel->deactivate(); @@ -1355,24 +1359,210 @@ void LLIMModel::LLIMSession::chatFromLogFile(LLLogChat::ELogLineType type, const void LLIMModel::LLIMSession::loadHistory() { - mMsgs.clear(); mLastHistoryCacheMsgs.clear(); mLastHistoryCacheDateTime.clear(); - if ( gSavedPerAccountSettings.getBOOL("LogShowHistory") ) + if (!gSavedPerAccountSettings.getBOOL("LogShowHistory")) + { + mChatHistoryLoadToken = ++sChatHistoryLoadToken; + mChatHistoryLocalLoading = false; + mChatServiceHistoricalValue.clear(); + replaceHistoricalMessages(mChatServiceHistoricalValue); + return; + } + if (LLChatServiceHistory::historySuppressed()) + { + mChatHistoryLoadToken = ++sChatHistoryLoadToken; + mChatHistoryLocalLoading = false; + mChatServiceHistoricalValue.clear(); + replaceHistoricalMessages(mChatServiceHistoricalValue); + return; + } + if (!isP2P()) { - // read and parse chat history from local file chat_message_list_t chat_history; LLLogChat::loadChatHistory(mHistoryFileName, chat_history, LLSD(), isGroupChat()); - addMessagesFromHistoryCache(chat_history); + replaceHistoricalMessages(chat_history); + return; + } + + const U64 token = mChatHistoryLoadToken = ++sChatHistoryLoadToken; + const LLUUID session_id = mSessionID; + const LLUUID participant_id = mOtherParticipantID; + mChatHistoryLocalLoading = true; + if (!LLChatServiceHistory::loadStitchedHistory( + participant_id, mHistoryFileName, 200, + [session_id, participant_id, token](const LLChatServiceHistory::HistoryResult& result) + { + LLIMSession* session = LLIMModel::instance().findIMSession(session_id); + if (!session || session->mOtherParticipantID != participant_id || + session->mChatHistoryLoadToken != token) + { + return; + } + if (result.account_epoch != LLChatServiceHistory::accountEpoch() || + LLChatServiceHistory::historySuppressed() || + !gSavedPerAccountSettings.getBOOL("LogShowHistory")) + { + session->mChatHistoryLocalLoading = false; + return; + } + const LLChatServiceHistory::Snapshot snapshot = + LLChatServiceHistory::getSnapshot(participant_id); + if (result.included_service != snapshot.service_presentation_allowed) + { + session->loadHistory(); + return; + } + if (result.archive_serial != snapshot.archive_serial) + { + session->loadHistory(); + return; + } + session->mChatHistoryArchiveSerial = result.archive_serial; + session->mChatHistoryLocalLoading = false; + session->mChatServiceHistoricalValue = LLChatServiceHistory::mergeHeadPreview( + result.messages, snapshot, 200); + session->replaceHistoricalMessages(session->mChatServiceHistoricalValue); + })) + { + mChatHistoryLocalLoading = false; + if (!LLChatServiceHistory::historySuppressed()) + { + chat_message_list_t legacy; + LLLogChat::loadChatHistory(mHistoryFileName, legacy); + mChatServiceHistoricalValue = legacy; + replaceHistoricalMessages(legacy); + } } } +void LLIMModel::LLIMSession::replaceHistoricalMessages(const chat_message_list_t& history) +{ + // Preserve every live row in its current order, replacing only rows tagged historical. + chat_message_list_t live; + for (const LLSD& message : mMsgs) + { + if (!message["is_history"].asBoolean()) live.push_back(message); + } + chat_message_list_t historical; + for (const LLSD& source : history) + { + LLSD message = source; + message["from"] = source[LL_IM_FROM]; + message["from_id"] = source[LL_IM_FROM_ID]; + message["message"] = source[LL_IM_TEXT]; + message["time"] = source[LL_IM_TIME]; + message["timestamp"] = 0; + message["is_history"] = true; + message["is_region_msg"] = false; + historical.push_front(message); + } + live.insert(live.end(), historical.begin(), historical.end()); + mMsgs.swap(live); + S32 index = static_cast<S32>(mMsgs.size()); + for (LLSD& message : mMsgs) message["index"] = --index; + if (LLFloaterIMSession* floater = LLFloaterIMSession::findInstance(mSessionID)) + floater->reloadMessages(false); +} + +void LLIMModel::LLIMSession::clearHistoricalMessages() +{ + mChatHistoryLoadToken = ++sChatHistoryLoadToken; + mChatHistoryLocalLoading = false; + mChatServiceHistoricalValue.clear(); + replaceHistoricalMessages(mChatServiceHistoricalValue); +} + +void LLIMModel::LLIMSession::clearForHistoryDeletion() +{ + // Delete is the only operation that removes live and historical model rows together. + mChatHistoryLoadToken = ++sChatHistoryLoadToken; + mChatHistoryLocalLoading = false; + mChatHistoryArchiveSerial = 0; + mChatServicePresentationAllowed = false; + mChatServiceHistoricalValue.clear(); + mLastHistoryCacheDateTime.clear(); + mLastHistoryCacheMsgs.clear(); + mMsgs.clear(); + mNumUnread = 0; + mParticipantUnreadMessageCount = 0; + mHasOfflineMessage = false; + if (LLFloaterIMSession* floater = LLFloaterIMSession::findInstance(mSessionID)) + floater->reloadMessages(false); +} + +void LLIMModel::LLIMSession::applyChatServiceSnapshot( + const LLChatServiceHistory::Snapshot& snapshot) +{ + if (!gSavedPerAccountSettings.getBOOL("LogShowHistory") || + LLChatServiceHistory::historySuppressed()) return; + const bool presentation_changed = + snapshot.service_presentation_allowed != mChatServicePresentationAllowed; + mChatServicePresentationAllowed = snapshot.service_presentation_allowed; + if (presentation_changed && !snapshot.service_presentation_allowed) + { + chat_message_list_t legacy_only; + for (const LLSD& message : mChatServiceHistoricalValue) + { + if (!message["chat_service_msg_id"].isString()) legacy_only.push_back(message); + } + mChatServiceHistoricalValue.swap(legacy_only); + replaceHistoricalMessages(mChatServiceHistoricalValue); + } + if (snapshot.service_presentation_allowed && !snapshot.head_preview.empty()) + { + // Merge the bounded first page in memory; the durable archive remains the seam authority. + mChatServiceHistoricalValue = LLChatServiceHistory::mergeHeadPreview( + mChatServiceHistoricalValue, snapshot, 200); + replaceHistoricalMessages(mChatServiceHistoricalValue); + } + if (presentation_changed || snapshot.archive_serial != mChatHistoryArchiveSerial) + { + loadHistory(); + } +} + +void LLIMModel::LLIMSession::startHistoryLoading() +{ + if (!isP2P()) + { + loadHistory(); + return; + } + const LLUUID session_id = mSessionID; + const LLUUID participant_id = mOtherParticipantID; + mChatServiceSnapshotConnection = LLChatServiceHistory::setSnapshotChanged( + [session_id, participant_id](const LLUUID& changed, + const LLChatServiceHistory::Snapshot& snapshot) + { + if (changed != participant_id) return; + LLIMSession* session = LLIMModel::instance().findIMSession(session_id); + if (session && session->mOtherParticipantID == participant_id) + session->applyChatServiceSnapshot(snapshot); + }); + const LLChatServiceHistory::Snapshot snapshot = + LLChatServiceHistory::getSnapshot(participant_id); + mChatHistoryArchiveSerial = snapshot.archive_serial; + mChatServicePresentationAllowed = snapshot.service_presentation_allowed; + applyChatServiceSnapshot(snapshot); + LLChatServiceHistory::prioritizeResident(participant_id); + loadHistory(); +} + LLIMModel::LLIMSession* LLIMModel::findIMSession(const LLUUID& session_id) const { return get_if_there(mId2SessionMap, session_id, (LLIMModel::LLIMSession*) NULL); } +void LLIMModel::reloadDirectHistories() +{ + for (auto& pair : mId2SessionMap) + { + if (pair.second->isP2P()) pair.second->loadHistory(); + } +} + //*TODO consider switching to using std::set instead of std::list for holding LLUUIDs across the whole code LLIMModel::LLIMSession* LLIMModel::findAdHocIMSession(const uuid_vec_t& ids) { @@ -1586,6 +1776,7 @@ bool LLIMModel::newSession(const LLUUID& session_id, const std::string& name, co LLIMSession *session = new LLIMSession(session_id, name, type, other_participant_id, voiceChannelInfo, ids, has_offline_msg); mId2SessionMap[session_id] = session; + session->startHistoryLoading(); // When notifying observer, name of session is used instead of "name", because they may not be the // same if it is an adhoc session (in this case name is localized in LLIMSession constructor). @@ -3274,6 +3465,13 @@ void LLIMMgr::addMessage( if (!LLMuteList::getInstance()->isMuted(other_participant_id, LLMute::flagTextChat) && !skip_message) { + // Accepted inbound direct rows share the same immediate priority path used by an opener. + if (LLChatServiceHistory::isPersistedDirectDialog(dialog) && + !is_region_msg && message_display_name != SYSTEM_FROM && + other_participant_id.notNull() && other_participant_id != gAgentID) + { + LLChatServiceHistory::prioritizeResident(other_participant_id, true); + } LLIMModel::instance().addMessage(new_session_id, message_display_name, display_id, msg, true, is_region_msg, timestamp); } @@ -4353,4 +4551,3 @@ LLHTTPRegistration<LLViewerChatterBoxSessionUpdate> LLHTTPRegistration<LLViewerChatterBoxInvitation> gHTTPRegistrationMessageChatterBoxInvitation( "/message/ChatterBoxInvitation"); - diff --git a/indra/newview/llimview.h b/indra/newview/llimview.h index 23f90ca795..d3d4eb1d86 100644 --- a/indra/newview/llimview.h +++ b/indra/newview/llimview.h @@ -30,6 +30,7 @@ #include "../llui/lldockablefloater.h" #include "lleventtimer.h" #include "llinstantmessage.h" +#include "llchatservicehistory.h" #include "lllogchat.h" #include "llvoicechannel.h" @@ -117,6 +118,12 @@ public: void buildHistoryFileName(); void loadHistory(); + void startHistoryLoading(); + void replaceHistoricalMessages(const chat_message_list_t& history); + void applyChatServiceSnapshot(const LLChatServiceHistory::Snapshot& snapshot); + void clearHistoricalMessages(); + void clearForHistoryDeletion(); + bool isChatHistoryLoading() const { return mChatHistoryLocalLoading; } LLUUID mSessionID; std::string mName; @@ -140,6 +147,11 @@ public: S32 mNumUnread; chat_message_list_t mMsgs; + chat_message_list_t mChatServiceHistoricalValue; + U64 mChatHistoryLoadToken = 0; + U32 mChatHistoryArchiveSerial = 0; + bool mChatHistoryLocalLoading = false; + bool mChatServicePresentationAllowed = false; LLVoiceChannel* mVoiceChannel; LLIMSpeakerMgr* mSpeakers; @@ -166,6 +178,7 @@ public: static LLUUID generateHash(const std::set<LLUUID>& sorted_uuids); boost::signals2::connection mAvatarNameCacheConnection; + boost::signals2::connection mChatServiceSnapshotConnection; }; @@ -182,6 +195,7 @@ public: * Returns NULL if the session does not exist */ LLIMSession* findIMSession(const LLUUID& session_id) const; + void reloadDirectHistories(); /** * Find an Ad-Hoc IM Session with specified participants diff --git a/indra/newview/lllogchat.cpp b/indra/newview/lllogchat.cpp index 59f6993a0b..e8bc2bb43d 100644 --- a/indra/newview/lllogchat.cpp +++ b/indra/newview/lllogchat.cpp @@ -29,6 +29,7 @@ #include "llagent.h" #include "llagentui.h" #include "llavatarnamecache.h" +#include "llchatservicehistory.h" #include "lllogchat.h" #include "llregex.h" #include "lltrans.h" @@ -48,6 +49,11 @@ #include <boost/date_time/posix_time/posix_time.hpp> #include <boost/date_time/local_time_adjustor.hpp> +#if !LL_WINDOWS +#include <fcntl.h> +#include <unistd.h> +#endif + const S32 LOG_RECALL_SIZE = 20480; const std::string LL_IM_TIME("time"); @@ -114,6 +120,12 @@ const static int IDX_TEXT = 3; using namespace boost::posix_time; using namespace boost::gregorian; +namespace +{ + // A save and the deletion sweep linearize at this feature-private boundary. + LLMutex sTranscriptMutationMutex; +} + void append_to_last_message(std::list<LLSD>& messages, const std::string& line) { if (!messages.size()) return; @@ -133,6 +145,49 @@ const char* remove_utf8_bom(const char* buf) return start; } +namespace +{ + void parseHistoryFile(LLFILE* input, std::list<LLSD>& messages, + const LLSD& load_params, bool load_all) + { + char buffer[LOG_RECALL_SIZE]; + bool skip_partial_line = true; + if (load_all || fseek(input, (LOG_RECALL_SIZE - 1) * -1, SEEK_END)) + { + skip_partial_line = false; + if (fseek(input, 0, SEEK_SET)) return; + } + while (fgets(buffer, LOG_RECALL_SIZE, input)) + { + size_t length = strlen(buffer); + while (length && (buffer[length - 1] == '\n' || buffer[length - 1] == '\r')) + buffer[--length] = '\0'; + if (skip_partial_line) + { + skip_partial_line = false; + continue; + } + std::string line(remove_utf8_bom(buffer)); + if (line.find("/mention)") != std::string::npos) + { + static const boost::regex mention_regex( + "\\[@([^\\]]+)\\]\\((" APP_HEADER_REGEX "/agent/[\\da-f-]+/mention)\\)", + boost::regex::perl | boost::regex::icase); + line = boost::regex_replace(line, mention_regex, "$2"); + } + if (!line.empty() && line[0] == ' ') + { + line.erase(0, MULTI_LINE_PREFIX.length()); + append_to_last_message(messages, '\n' + line); + continue; + } + LLSD item; + if (!LLChatLogParser::parse(line, item, load_params)) item[LL_IM_TEXT] = line; + messages.push_back(item); + } + } +} + class LLLogChatTimeScanner: public LLSingleton<LLLogChatTimeScanner> { LLSINGLETON(LLLogChatTimeScanner); @@ -355,57 +410,64 @@ void LLLogChat::saveHistory(const std::string& filename, return; } - llofstream file(LLLogChat::makeLogFileName(filename).c_str(), std::ios_base::app); - if (!file.is_open()) { - LL_WARNS() << "Couldn't open chat history log! - " + filename << LL_ENDL; - return; - } + // Keep the whole open/write/flush/close operation on one side of a Delete sweep. + LLMutexLock mutation_lock(&sTranscriptMutationMutex); + llofstream file(LLLogChat::makeLogFileName(filename).c_str(), std::ios_base::app); + if (!file.is_open()) + { + LL_WARNS() << "Couldn't open chat history log! - " + filename << LL_ENDL; + return; + } - std::string altered_line = line; + std::string altered_line = line; // avoid costly regex calls - if (line.find("/mention") != std::string::npos) - { - static const boost::regex mention_regex(APP_HEADER_REGEX "/agent/[\\da-f-]+/mention", boost::regex::perl | boost::regex::icase); + if (line.find("/mention") != std::string::npos) + { + static const boost::regex mention_regex(APP_HEADER_REGEX "/agent/[\\da-f-]+/mention", boost::regex::perl | boost::regex::icase); // replace mention URL with [@username](URL) - altered_line = boost::regex_replace(line, mention_regex, [](const boost::smatch& match) -> std::string - { - std::string url = match[0].str(); - std::string username = LLUrlAction::getURLLabel(url); - return "[" + username + "](" + url + ")"; - }); - } + altered_line = boost::regex_replace(line, mention_regex, [](const boost::smatch& match) -> std::string + { + std::string url = match[0].str(); + std::string username = LLUrlAction::getURLLabel(url); + return "[" + username + "](" + url + ")"; + }); + } - LLSD item; + LLSD item; - if (gSavedPerAccountSettings.getBOOL("LogTimestamp")) - item["time"] = LLLogChat::timestamp2LogString(0, gSavedPerAccountSettings.getBOOL("LogTimestampDate")); + if (gSavedPerAccountSettings.getBOOL("LogTimestamp")) + item["time"] = LLLogChat::timestamp2LogString(0, gSavedPerAccountSettings.getBOOL("LogTimestampDate")); - item["from_id"] = from_id; - item["message"] = altered_line; + item["from_id"] = from_id; + item["message"] = altered_line; //adding "Second Life:" for all system messages to make chat log history parsing more reliable - if (from.empty() && from_id.isNull()) - { - item["from"] = SYSTEM_FROM; - } - else - { - item["from"] = from; - } - - file << LLChatLogFormatter(item) << std::endl; + if (from.empty() && from_id.isNull()) + { + item["from"] = SYSTEM_FROM; + } + else + { + item["from"] = from; + } - file.close(); + file << LLChatLogFormatter(item) << std::endl; + file.close(); + } - LLLogChat::getInstance()->triggerHistorySignal(); + LLLogChat::notifyTranscriptCreated(); } // static void LLLogChat::loadChatHistory(const std::string& file_name, std::list<LLSD>& messages, const LLSD& load_params, bool is_group) { + if (LLChatServiceHistory::historySuppressed()) + { + return; + } if (file_name.empty()) { LL_WARNS("LLLogChat::loadChatHistory") << "Local history file name is empty!" << LL_ENDL; @@ -461,74 +523,67 @@ void LLLogChat::loadChatHistory(const std::string& file_name, std::list<LLSD>& m } auto save_num_messages = messages.size(); + parseHistoryFile(fptr, messages, load_params, load_all_history); + fclose(fptr); - char buffer[LOG_RECALL_SIZE]; /*Flawfinder: ignore*/ - char *bptr; - size_t len; - bool firstline = true; + LL_DEBUGS("ChatHistory") << "Read " << (messages.size() - save_num_messages) + << " messages of chat history from " << log_file_name + << " file mod time " << (F64)stat_data.st_mtime << LL_ENDL; +} - if (load_all_history || fseek(fptr, (LOG_RECALL_SIZE - 1) * -1 , SEEK_END)) - { //We need to load the whole historyFile or it's smaller than recall size, so get it all. - firstline = false; - if (fseek(fptr, 0, SEEK_SET)) - { - fclose(fptr); - return; - } +// static +void LLLogChat::getTranscriptFamily(const std::string& file_name, std::vector<std::string>& paths) +{ + paths.clear(); + if (file_name.empty()) + { + return; } - while (fgets(buffer, LOG_RECALL_SIZE, fptr) && !feof(fptr)) + const std::string stem = cleanFileName(file_name); + const std::string directory = gDirUtilp->getPerAccountChatLogsDir() + + gDirUtilp->getDirDelimiter(); + const std::string ordinary = directory + stem + '.' + LL_TRANSCRIPT_FILE_EXTENSION; + if (LLFile::isfile(ordinary)) { - len = strlen(buffer) - 1; /*Flawfinder: ignore*/ - // backfill any end of line characters with nulls - for (bptr = (buffer + len); (*bptr == '\n' || *bptr == '\r') && bptr>buffer; bptr--) *bptr='\0'; - - if (firstline) - { - firstline = false; - continue; - } - - std::string line(remove_utf8_bom(buffer)); - + paths.push_back(ordinary); + } - // fast heuristic test for a mention URL in a string - // this is used to avoid costly regex calls - if (line.find("/mention)") != std::string::npos) - { - // restore original mention URL from [@username](URL) format - static const boost::regex altered_mention_regex("\\[@([^\\]]+)\\]\\((" APP_HEADER_REGEX "/agent/[\\da-f-]+/mention)\\)", - boost::regex::perl | boost::regex::icase); + // Monthly shards follow the ordinary file in lexical order for deterministic seam rows. + std::vector<std::string> shards; + LLDirIterator iterator(directory, stem + "-????-??." + LL_TRANSCRIPT_FILE_EXTENSION); + std::string name; + while (iterator.next(name)) + { + shards.push_back(directory + name); + } + std::sort(shards.begin(), shards.end()); + paths.insert(paths.end(), shards.begin(), shards.end()); +} - // $2 captures the URL part - line = boost::regex_replace(line, altered_mention_regex, "$2"); - } +// static +void LLLogChat::loadChatHistoryExact(const std::string& path, std::list<LLSD>& messages, + const LLSD& load_params) +{ + if (LLChatServiceHistory::historySuppressed()) return; + loadChatHistoryExactUnchecked(path, messages, load_params); +} - //updated 1.23 plain text log format requires a space added before subsequent lines in a multilined message - if (' ' == line[0]) - { - line.erase(0, MULTI_LINE_PREFIX.length()); - append_to_last_message(messages, '\n' + line); - } - else if (0 == len && ('\n' == line[0] || '\r' == line[0])) - { - //to support old format's multilined messages with new lines used to divide paragraphs - append_to_last_message(messages, line); - } - else - { - LLSD item; - if (!LLChatLogParser::parse(line, item, load_params)) - { - item[LL_IM_TEXT] = line; - } - messages.push_back(item); - } +// static +void LLLogChat::loadChatHistoryExactUnchecked( + const std::string& path, std::list<LLSD>& messages, const LLSD& load_params) +{ + if (path.empty()) + { + return; + } + LLFILE* input = LLFile::fopen(path, LLFILE_MODE("rb")); + if (!input) + { + return; } - fclose(fptr); - LL_DEBUGS("ChatHistory") << "Read " << (messages.size() - save_num_messages) - << " messages of chat history from " << log_file_name - << " file mod time " << (F64)stat_data.st_mtime << LL_ENDL; + parseHistoryFile(input, messages, load_params, true); + fclose(input); } bool LLLogChat::historyThreadsFinished(LLUUID session_id) @@ -817,9 +872,34 @@ bool LLLogChat::moveTranscripts(const std::string currentDirectory, //static void LLLogChat::deleteTranscripts() { + deleteTranscriptContent(); + LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); +} + +// static +bool LLLogChat::deleteTranscriptContent() +{ + return deleteTranscriptContent(gDirUtilp->getPerAccountChatLogsDir()); +} + +// static +bool LLLogChat::deleteTranscriptContent(const std::string& directory) +{ + // Snapshot and remove the exact legacy targets while plaintext writers are excluded. + LLMutexLock mutation_lock(&sTranscriptMutationMutex); std::vector<std::string> list_of_transcriptions; - getListOfTranscriptFiles(list_of_transcriptions); - getListOfTranscriptBackupFiles(list_of_transcriptions); + const std::string separator = !directory.empty() && + (directory.back() == '/' || directory.back() == '\\') ? "" : "/"; + for (const std::string& pattern : { std::string("*.") + LL_TRANSCRIPT_FILE_EXTENSION, + std::string("*.") + LL_TRANSCRIPT_FILE_EXTENSION + ".backup*" }) + { + LLDirIterator iterator(directory, pattern); + std::string filename; + while (iterator.next(filename)) + { + list_of_transcriptions.push_back(directory + separator + filename); + } + } for (const std::string& fullpath : list_of_transcriptions) { @@ -836,7 +916,7 @@ void LLLogChat::deleteTranscripts() if(retry_count >= 5) { LL_WARNS("LLLogChat::deleteTranscripts") << "Failed to remove " << fullpath << LL_ENDL; - return; + return false; } ms_sleep(100); @@ -852,7 +932,22 @@ void LLLogChat::deleteTranscripts() } } - LLFloaterIMSessionTab::processChatHistoryStyleUpdate(true); +#if LL_WINDOWS + return true; +#else + // Commit removed directory entries before durable Delete can publish completion. + const int descriptor = ::open(directory.c_str(), O_RDONLY | O_DIRECTORY); + if (descriptor < 0) return false; + const bool synced = ::fsync(descriptor) == 0; + ::close(descriptor); + return synced; +#endif +} + +// static +void LLLogChat::notifyTranscriptCreated() +{ + LLLogChat::getInstance()->triggerHistorySignal(); } // static diff --git a/indra/newview/lllogchat.h b/indra/newview/lllogchat.h index 39c40b985e..0474fe7cc3 100644 --- a/indra/newview/lllogchat.h +++ b/indra/newview/lllogchat.h @@ -109,6 +109,10 @@ public: static void getListOfTranscriptBackupFiles(std::vector<std::string>& list_of_transcriptions); static void loadChatHistory(const std::string& file_name, std::list<LLSD>& messages, const LLSD& load_params = LLSD(), bool is_group = false); + // ChatService resolves the family once, then reuses this canonical parser per exact path. + static void getTranscriptFamily(const std::string& file_name, std::vector<std::string>& paths); + static void loadChatHistoryExact(const std::string& path, std::list<LLSD>& messages, + const LLSD& load_params = LLSD()); typedef boost::signals2::signal<void ()> save_history_signal_t; boost::signals2::connection setSaveHistorySignal(const save_history_signal_t::slot_type& cb); @@ -122,6 +126,9 @@ public: std::vector<std::string>& listOfFilesToMove); static void deleteTranscripts(); + static bool deleteTranscriptContent(); + static bool deleteTranscriptContent(const std::string& directory); + static void notifyTranscriptCreated(); static bool isTranscriptExist(const LLUUID& avatar_id, bool is_group=false); static bool isNearbyTranscriptExist(); static bool isAdHocTranscriptExist(std::string file_name); @@ -137,6 +144,10 @@ public: void cleanupHistoryThreads(); private: + friend struct LLChatServiceHistoryAccess; + static void loadChatHistoryExactUnchecked(const std::string& path, + std::list<LLSD>& messages, + const LLSD& load_params = LLSD()); static std::string cleanFileName(std::string filename); LLMutex* historyThreadsMutex(); diff --git a/indra/newview/llnotificationhandlerutil.cpp b/indra/newview/llnotificationhandlerutil.cpp index cdf7f05ada..ca11107f61 100644 --- a/indra/newview/llnotificationhandlerutil.cpp +++ b/indra/newview/llnotificationhandlerutil.cpp @@ -327,11 +327,11 @@ void LLHandlerUtil::decIMMesageCounter(const LLNotificationPtr& notification) { LLSD arg; arg["session_id"] = session_id; - session->mNumUnread--; + session->mNumUnread = llmax(0, session->mNumUnread - 1); arg["num_unread"] = session->mNumUnread; - session->mParticipantUnreadMessageCount--; + session->mParticipantUnreadMessageCount = + llmax(0, session->mParticipantUnreadMessageCount - 1); arg["participant_unread"] = session->mParticipantUnreadMessageCount; LLIMModel::getInstance()->mNewMsgSignal(arg); } } - diff --git a/indra/newview/llpanelpeoplemenus.cpp b/indra/newview/llpanelpeoplemenus.cpp index 62e726d21d..2640285ecd 100644 --- a/indra/newview/llpanelpeoplemenus.cpp +++ b/indra/newview/llpanelpeoplemenus.cpp @@ -38,6 +38,7 @@ #include "llavataractions.h" #include "llcallingcard.h" // for LLAvatarTracker #include "lllogchat.h" +#include "llchatservicehistory.h" #include "llparcel.h" #include "llviewermenu.h" // for gMenuHolder #include "llconversationmodel.h" @@ -247,7 +248,9 @@ bool PeopleContextMenu::enableContextMenuItem(const LLSD& userdata) } else if (item == std::string("can_callog")) { - return LLLogChat::isTranscriptExist(mUUIDs.front()); + return !LLChatServiceHistory::historySuppressed() && + (LLLogChat::isTranscriptExist(mUUIDs.front()) || + LLChatServiceHistory::localHistoryExists(mUUIDs.front())); } else if (item == std::string("can_im") || item == std::string("can_invite")) { diff --git a/indra/newview/llpanelprofile.cpp b/indra/newview/llpanelprofile.cpp index b33696244d..417fa57f6d 100644 --- a/indra/newview/llpanelprofile.cpp +++ b/indra/newview/llpanelprofile.cpp @@ -57,6 +57,7 @@ #include "llagent.h" //gAgent #include "llagentpicksinfo.h" #include "llavataractions.h" +#include "llchatservicehistory.h" #include "llavatarpropertiesprocessor.h" #include "llcallingcard.h" #include "llcommandhandler.h" @@ -1566,7 +1567,9 @@ bool LLPanelProfileSecondLife::onEnableMenu(const LLSD& userdata) } else if (item_name == "chat_history") { - return LLLogChat::isTranscriptExist(agent_id); + return !LLChatServiceHistory::historySuppressed() && + (LLLogChat::isTranscriptExist(agent_id) || + LLChatServiceHistory::localHistoryExists(agent_id)); } else if (item_name == "add_friend") { @@ -2490,4 +2493,3 @@ void LLPanelProfile::createClassified() mPanelClassifieds->createClassified(); mTabContainer->selectTabPanel(mPanelClassifieds); } - diff --git a/indra/newview/llviewerregion.cpp b/indra/newview/llviewerregion.cpp index 0bb3edfc7e..3ec12e46e8 100644 --- a/indra/newview/llviewerregion.cpp +++ b/indra/newview/llviewerregion.cpp @@ -3226,6 +3226,8 @@ void LLViewerRegionImpl::buildCapabilityNames(LLSD& capabilityNames) capabilityNames.append("AvatarRenderInfo"); capabilityNames.append("CharacterProperties"); capabilityNames.append("ChatSessionRequest"); + capabilityNames.append("PersistentChatConversationsRequest"); + capabilityNames.append("PersistentChatHistoryRequest"); capabilityNames.append("CopyInventoryFromNotecard"); capabilityNames.append("CreateInventoryCategory"); capabilityNames.append("DeclineFriendship"); @@ -3840,4 +3842,3 @@ void LLViewerRegion::applyCacheMiscExtras(LLViewerObject* obj) } } } - diff --git a/indra/newview/skins/default/xui/en/floater_conversation_preview.xml b/indra/newview/skins/default/xui/en/floater_conversation_preview.xml index 27913baff6..c534178579 100644 --- a/indra/newview/skins/default/xui/en/floater_conversation_preview.xml +++ b/indra/newview/skins/default/xui/en/floater_conversation_preview.xml @@ -60,4 +60,28 @@ top_delta="4" width="40"> </text> + <panel + background_visible="true" + bg_alpha_color="DkGray2" + name="chat_service_loading" + follows="top|right" + height="24" + right="-8" + top="28" + visible="false" + width="105"> + <loading_indicator + height="20" + left="0" + name="chat_service_loading_wheel" + top="2" + width="20" /> + <text + height="20" + left_pad="4" + name="chat_service_loading_text" + top="2" + value="Loading..." + width="80" /> + </panel> </floater> diff --git a/indra/newview/skins/default/xui/en/floater_im_session.xml b/indra/newview/skins/default/xui/en/floater_im_session.xml index 7b06bed0a3..fbce7e095e 100644 --- a/indra/newview/skins/default/xui/en/floater_im_session.xml +++ b/indra/newview/skins/default/xui/en/floater_im_session.xml @@ -232,6 +232,30 @@ left="0" top="0" bottom="-1" /> + <panel + background_visible="true" + bg_alpha_color="DkGray2" + name="chat_service_loading" + follows="top|right" + height="24" + right="-8" + top="8" + visible="false" + width="105"> + <loading_indicator + height="20" + left="0" + name="chat_service_loading_wheel" + top="2" + width="20" /> + <text + height="20" + left_pad="4" + name="chat_service_loading_text" + top="2" + value="Loading..." + width="80" /> + </panel> </layout_panel> </layout_stack> </layout_panel> diff --git a/indra/newview/skins/default/xui/en/notifications.xml b/indra/newview/skins/default/xui/en/notifications.xml index a9adb772e5..5ef0833457 100644 --- a/indra/newview/skins/default/xui/en/notifications.xml +++ b/indra/newview/skins/default/xui/en/notifications.xml @@ -12228,7 +12228,7 @@ Cannot create large prims that intersect other residents. Please re-try when ot icon="alertmodal.tga" name="PreferenceChatDeleteTranscripts" type="alertmodal"> - This will delete the transcripts for all previous conversations. The list of past conversations will not be affected. All files with the suffixes .txt and txt.backup in the folder [FOLDER] will be deleted. + This removes local transcript files and downloaded ChatService history. Remote records are retained, but service rows at or below the recorded deletion boundary remain hidden locally. The list of past conversations is not affected. <tag>confirm</tag> <usetemplate ignoretext="Confirm before I delete transcripts." @@ -12239,6 +12239,13 @@ Cannot create large prims that intersect other residents. Please re-try when ot <notification icon="alert.tga" + name="ChatServiceHistoryDeleteFailed" + type="alert"> + Some local history could not be deleted safely. History remains hidden until Delete Transcripts succeeds. + </notification> + + <notification + icon="alert.tga" name="PreferenceChatPathChanged" type="alert"> Unable to move files. Restored previous path. diff --git a/indra/newview/tests/llchatservicehistorycore_test.cpp b/indra/newview/tests/llchatservicehistorycore_test.cpp new file mode 100644 index 0000000000..7b5cbe9dfe --- /dev/null +++ b/indra/newview/tests/llchatservicehistorycore_test.cpp @@ -0,0 +1,201 @@ +/** + * @file llchatservicehistorycore_test.cpp + * @brief Strict ChatService wire, TimeUUID, and archive tests. + */ +#include "linden_common.h" +#include "../test/lltut.h" +#include "../test/namedtempfile.h" +#include "../llchatservicehistorycore.h" + +namespace tut +{ +using namespace LLChatServiceHistoryCore; + +struct chat_service_history_core {}; +typedef test_group<chat_service_history_core> group_t; +typedef group_t::object object_t; +group_t group("LLChatServiceHistoryCore"); + +const LLUUID AGENT("00000000-0000-0000-0000-000000000001"); +const LLUUID RESIDENT("00000000-0000-0000-0000-000000000002"); +const char* const FIRST = "00000001-0000-1000-8000-000000000000"; +const char* const SECOND = "00000002-0000-1000-8000-000000000000"; + +LLSD wireRow(const std::string& id, const LLUUID& from = RESIDENT) +{ + LLSD row; + row["conversation_id"] = directConversationId(AGENT, RESIDENT); + row["msg_id"] = id; + row["from_id"] = from.asString(); + row["from_name"] = "Resident"; + row["message"] = "hello"; + row["dialog"] = 0; + row["created_at"] = "2026-08-12T12:34:56.123Z"; + return row; +} + +LLSD wirePage(const LLSD& rows, const std::string& cursor = std::string()) +{ + LLSD page; + page["conversation_id"] = directConversationId(AGENT, RESIDENT); + page["limit"] = 100; + page["messages"] = rows; + if (!cursor.empty()) page["before_msg_id"] = cursor; + return page; +} + +template<> template<> void object_t::test<1>() +{ + ensure_equals("deterministic direct id", directConversationId(RESIDENT, AGENT), + AGENT.asString() + "_" + RESIDENT.asString()); +} + +template<> template<> void object_t::test<2>() +{ + TimeUuidKey first, second; + ensure("UUIDv1 parses", parseTimeUuid(FIRST, first)); + ensure("timestamp ordering", parseTimeUuid(SECOND, second) && first < second); + ensure("UUIDv4 rejected", !parseTimeUuid( + "00000001-0000-4000-8000-000000000000", first)); + ensure("noncanonical UUID rejected", !parseTimeUuid( + "00000001-0000-1000-8000-00000000000A", first)); +} + +template<> template<> void object_t::test<3>() +{ + TimeUuidKey low, high; + parseTimeUuid(FIRST, low); + parseTimeUuid("00000001-0000-1000-bfff-000000000000", high); + ensure("Cassandra signed tail ordering", low < high); +} + +template<> template<> void object_t::test<4>() +{ + LLSD rows = LLSD::emptyArray(); + rows.append(wireRow(SECOND)); + rows.append(wireRow(FIRST, AGENT)); + Page page; + ensure("strict descending page", validateHistoryPage( + wirePage(rows), AGENT, RESIDENT, directConversationId(AGENT, RESIDENT), + "", 0, page)); + ensure_equals("all rows retained", page.rows.size(), size_t(2)); + ensure_equals("oldest cursor", page.next_cursor, std::string(FIRST)); +} + +template<> template<> void object_t::test<5>() +{ + LLSD rows = LLSD::emptyArray(); + rows.append(wireRow(FIRST)); + rows.append(wireRow(SECOND)); + Page page; + ensure("ascending page rejected", !validateHistoryPage( + wirePage(rows), AGENT, RESIDENT, directConversationId(AGENT, RESIDENT), + "", 0, page)); + LLSD wrong = wirePage(LLSD::emptyArray()); + wrong["limit"] = "100"; + ensure("coercible type rejected", !validateHistoryPage( + wrong, AGENT, RESIDENT, directConversationId(AGENT, RESIDENT), "", 0, page)); +} + +template<> template<> void object_t::test<6>() +{ + LLSD list = LLSD::emptyArray(); + LLSD entry; + entry["conversation_type"] = "direct"; + entry["conversation_id"] = directConversationId(AGENT, RESIDENT); + entry["other_participant_id"] = RESIDENT.asString(); + entry["last_msg_id"] = SECOND; + list.append(entry); + std::vector<ListEntry> entries; + ensure("strict list accepted", validateConversationList(list, AGENT, entries)); + list.append(entry); + ensure("duplicate resident rejected", !validateConversationList(list, AGENT, entries)); + LLSD null_list = LLSD::emptyArray(); + entry["other_participant_id"] = LLUUID::null.asString(); + entry["conversation_id"] = directConversationId(AGENT, LLUUID::null); + null_list.append(entry); + ensure("null resident rejected", !validateConversationList(null_list, AGENT, entries)); +} + +template<> template<> void object_t::test<7>() +{ + std::string normalized; + ensure("UTC timestamp", parseCreatedAt("2026-08-12T12:34:56Z", normalized)); + ensure("offset timestamp", parseCreatedAt("2026-08-12T12:34:56+02:30", normalized)); + ensure("offset-less timestamp", parseCreatedAt("2026-08-12T12:34:56", normalized)); + ensure("fractional timestamp", parseCreatedAt("2026-08-12T12:34:56.123Z", normalized)); + ensure("reject trailing timestamp data", + !parseCreatedAt("2026-08-12T12:34:56Z123", normalized)); + ensure("reject invalid offset", !parseCreatedAt("2026-08-12T12:34:56+99:99", normalized)); + ensure("reject signed date field", !parseCreatedAt("2026-+1-12T12:34:56Z", normalized)); + ensure("reject empty fraction", !parseCreatedAt("2026-08-12T12:34:56.Z", normalized)); + ensure("reject month", !parseCreatedAt("2026-13-12T12:34:56Z", normalized)); + ensure("reject day", !parseCreatedAt("2026-08-35T12:34:56Z", normalized)); + ensure("reject nonleap day", !parseCreatedAt("2026-02-29T12:34:56Z", normalized)); + ensure("accept leap day", parseCreatedAt("2024-02-29T12:34:56Z", normalized)); + ensure("reject hour", !parseCreatedAt("2026-08-12T24:34:56Z", normalized)); + ensure("reject minute", !parseCreatedAt("2026-08-12T12:60:56Z", normalized)); + ensure("reject second", !parseCreatedAt("2026-08-12T12:34:60Z", normalized)); + ensure("bad width", !parseCreatedAt("2026-8-12T12:34:56Z", normalized)); + ensure("dialog allowlist", persistedDirectDialog(38) && !persistedDirectDialog(99)); +} + +template<> template<> void object_t::test<8>() +{ + ensure_equals("RFC4180 quoting", quoteCsv("comma, quote \" and\nnewline"), + "\"comma, quote \"\" and\nnewline\""); +} + +std::string validArchive() +{ + Row first, second; + first.conversation_id = second.conversation_id = directConversationId(AGENT, RESIDENT); + first.msg_id = FIRST; + second.msg_id = SECOND; + first.from_id = AGENT; + second.from_id = RESIDENT; + first.from_name = second.from_name = "Resident"; + first.message = "first"; + second.message = "second, quoted"; + first.created_at = second.created_at = "2026-08-12T12:34:56Z"; + parseTimeUuid(first.msg_id, first.key); + parseTimeUuid(second.msg_id, second.key); + std::ostringstream output; + output << CSV_HEADER; + writeCsvRow(output, first); + writeCsvRow(output, second); + return output.str(); +} + +template<> template<> void object_t::test<9>() +{ + NamedTempFile file("chatservice", validArchive()); + ArchiveScan scan; + ensure("valid archive", scanArchive(file.getPath().string(), AGENT, RESIDENT, 0, 1, scan)); + ensure_equals("two summarized", scan.row_count, U32(2)); + ensure_equals("newest display cap", scan.display_rows.size(), size_t(1)); + ensure_equals("newest retained", scan.display_rows.front().msg_id, std::string(SECOND)); +} + +template<> template<> void object_t::test<10>() +{ + NamedTempFile file("chatservice", validArchive() + "\"unterminated"); + ArchiveScan scan; + ensure("torn EOF rejected", !scanArchive( + file.getPath().string(), AGENT, RESIDENT, 0, 0, scan)); + ensure_equals("torn distinguished", scan.state, ARCHIVE_TORN); +} + +template<> template<> void object_t::test<11>() +{ + std::string content = validArchive(); + const std::string needle = ",0,2026-08-12T12:34:56Z\n"; + const size_t last = content.rfind(needle); + content.replace(last, needle.size(), ",99,2026-08-12T12:34:56Z\n"); + NamedTempFile file("chatservice", content); + ArchiveScan scan; + ensure("complete malformed row rejected", !scanArchive( + file.getPath().string(), AGENT, RESIDENT, 0, 0, scan)); + ensure_equals("corrupt distinguished", scan.state, ARCHIVE_CORRUPT); +} +} |
