chats: throttle BML name lookups

Real-name lookups use BML's account validation, the same search as the
Transfer page, which BML rate-limits per user ("searched too many
times"). Look up only names the saved data can't answer (accounts sent
to that aren't contacts, contacts without a separate real name), at most
3 per run, one run per 6 hours, 15 per day, and back off for a day at the
first refusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
This commit is contained in:
2026-09-27 17:00:11 +05:00
co-authored by Claude Opus 5.5
parent 83ca37eade
commit 97fe63025d
2 changed files with 56 additions and 14 deletions
@@ -20,8 +20,16 @@ object AccountNameStore {
private const val REFRESH_AFTER_MS = 30L * 24 * 60 * 60 * 1000
/** Failed lookups (closed or foreign accounts) are retried after this long. */
private const val RETRY_FAILED_AFTER_MS = 24L * 60 * 60 * 1000
/** Upper bound on lookups per call, so a big contact list is resolved over several syncs. */
private const val MAX_LOOKUPS_PER_RUN = 20
/*
* BML's account validation is the same search the Transfer page uses, and BML rate-limits it
* per user ("searched too many times"). Lookups are a background nicety, so they get a small
* budget and back off at the first refusal, leaving the user's own searches alone.
*/
private const val MAX_LOOKUPS_PER_RUN = 3
private const val MIN_RUN_INTERVAL_MS = 6L * 60 * 60 * 1000
private const val MAX_LOOKUPS_PER_DAY = 15
private const val BACKOFF_MS = 24L * 60 * 60 * 1000
private const val BUDGET_PREFS = "chat_name_lookup_budget"
data class Info(val name: String, val currency: String)
@@ -32,34 +40,60 @@ object AccountNameStore {
load(context).filterValues { it.name.isNotBlank() }.mapValues { Info(it.value.name, it.value.currency) }
}
/** Looks up names for [accounts] that are unknown or stale. Network I/O — call on Dispatchers.IO. */
/**
* Looks up names for [accounts] (most important first) that are unknown or stale, within the
* budget above. Network I/O — call on Dispatchers.IO.
*/
fun resolve(context: Context, session: BmlSession, accounts: Collection<String>) {
val now = System.currentTimeMillis()
val budget = context.getSharedPreferences(BUDGET_PREFS, Context.MODE_PRIVATE)
if (now < budget.getLong("blockedUntil", 0L)) return
if (now - budget.getLong("lastRunAt", 0L) < MIN_RUN_INTERVAL_MS) return
val today = now / (24L * 60 * 60 * 1000)
val usedToday = if (budget.getLong("day", -1L) == today) budget.getInt("count", 0) else 0
val allowed = minOf(MAX_LOOKUPS_PER_RUN, MAX_LOOKUPS_PER_DAY - usedToday)
if (allowed <= 0) return
val known = synchronized(lock) { load(context) }
val due = accounts.filter { it.isNotBlank() }.distinct().filter { account ->
val e = known[account] ?: return@filter true
val age = now - e.checkedAt
if (e.name.isBlank()) age > RETRY_FAILED_AFTER_MS else age > REFRESH_AFTER_MS
}.take(MAX_LOOKUPS_PER_RUN)
}.take(allowed)
if (due.isEmpty()) return
val client = BmlValidateClient()
// null = network error: leave the account due for next time. A failed lookup is stored blank.
val found = due.associateWith { account ->
try {
val v = client.validateAccount(session, account)
Entry(v?.name?.trim().orEmpty(), v?.currency.orEmpty(), now)
} catch (_: Exception) { null }
val found = mutableMapOf<String, Entry>()
var used = 0
for (account in due) {
used++
val v = try { client.validateAccount(session, account) } catch (_: Exception) { null }
if (v == null || v.name.isBlank()) {
// Refused, limited, or unknown: stop for the day rather than keep asking. The
// account is marked failed so it's retried later, not first in line tomorrow.
found[account] = Entry("", "", now)
budget.edit().putLong("blockedUntil", now + BACKOFF_MS).apply()
break
}
found[account] = Entry(v.name.trim(), v.currency, now)
}
budget.edit()
.putLong("lastRunAt", now)
.putLong("day", today)
.putInt("count", usedToday + used)
.apply()
synchronized(lock) {
val current = load(context).toMutableMap()
found.forEach { (account, entry) -> if (entry != null) current[account] = entry }
current.putAll(found)
save(context, current)
}
}
fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() }
fun clearAll(context: Context) = synchronized(lock) {
File(context.filesDir, FILE_NAME).delete()
context.getSharedPreferences(BUDGET_PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
private fun load(context: Context): Map<String, Entry> {
val file = File(context.filesDir, FILE_NAME)
@@ -208,9 +208,17 @@ object ChatStore {
// Learn real names for contacts and people we've sent to, so name-only transfers match them.
val session = app.anyBmlSession() ?: return
// Only what the saved data can't answer, most useful first: accounts sent to that aren't
// contacts, then contacts saved without a separate real name. SWIFT (foreign) beneficiaries
// can't be looked up through BML.
val contactAccounts = contacts.mapTo(HashSet()) { it.benefAccount }
val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } }
// SWIFT (foreign) beneficiaries can't be looked up through BML.
val toResolve = contacts.filter { it.benefType != "S" }.map { it.benefAccount } + sentTo
.filter { it.isNotBlank() && it !in contactAccounts }
val nameless = contacts.filter {
it.benefType != "S" && it.benefAccount.isNotBlank() &&
(it.benefName.isBlank() || normalise(it.benefName) == normalise(it.benefNickName))
}.map { it.benefAccount }
val toResolve = sentTo + nameless
AccountNameStore.resolve(context, session, toResolve)
}