teams-cli 0.2.3__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {teams_cli-0.2.3 → teams_cli-0.2.5}/CHANGELOG.md +21 -0
  2. {teams_cli-0.2.3 → teams_cli-0.2.5}/Cargo.lock +5 -5
  3. {teams_cli-0.2.3 → teams_cli-0.2.5}/Cargo.toml +1 -1
  4. {teams_cli-0.2.3 → teams_cli-0.2.5}/PKG-INFO +1 -1
  5. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/browser.rs +4 -2
  6. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/helper.rs +1 -1
  7. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop.js +32 -7
  8. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop.rs +39 -7
  9. teams_cli-0.2.5/src/desktop_cache.js +48 -0
  10. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop_people.js +1 -1
  11. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/tui.rs +91 -2
  12. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/desktop_scripts.test.cjs +108 -8
  13. teams_cli-0.2.3/src/desktop_cache.js +0 -17
  14. {teams_cli-0.2.3 → teams_cli-0.2.5}/.github/workflows/ci.yml +0 -0
  15. {teams_cli-0.2.3 → teams_cli-0.2.5}/.github/workflows/dependencies.yml +0 -0
  16. {teams_cli-0.2.3 → teams_cli-0.2.5}/.github/workflows/release.yml +0 -0
  17. {teams_cli-0.2.3 → teams_cli-0.2.5}/.gitignore +0 -0
  18. {teams_cli-0.2.3 → teams_cli-0.2.5}/.mise.toml +0 -0
  19. {teams_cli-0.2.3 → teams_cli-0.2.5}/LICENSE +0 -0
  20. {teams_cli-0.2.3 → teams_cli-0.2.5}/Makefile +0 -0
  21. {teams_cli-0.2.3 → teams_cli-0.2.5}/README.md +0 -0
  22. {teams_cli-0.2.3 → teams_cli-0.2.5}/build.rs +0 -0
  23. {teams_cli-0.2.3 → teams_cli-0.2.5}/docs/releases.md +0 -0
  24. {teams_cli-0.2.3 → teams_cli-0.2.5}/examples/validate_schema.rs +0 -0
  25. {teams_cli-0.2.3 → teams_cli-0.2.5}/pyproject.toml +0 -0
  26. {teams_cli-0.2.3 → teams_cli-0.2.5}/scripts/smoke-release.py +0 -0
  27. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/auth/pass.rs +0 -0
  28. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/auth/process.rs +0 -0
  29. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/auth/wsl.rs +0 -0
  30. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/auth/wsl_credentials.ps1 +0 -0
  31. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/auth.rs +0 -0
  32. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/backend.rs +0 -0
  33. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/cli.rs +0 -0
  34. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/config.rs +0 -0
  35. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/delivery.rs +0 -0
  36. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/avatars.rs +0 -0
  37. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/browser_children.rs +0 -0
  38. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/browser_core.rs +0 -0
  39. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/browser_install.rs +0 -0
  40. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/browser_lifetime.rs +0 -0
  41. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/browser_setup.rs +0 -0
  42. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/cdp.rs +0 -0
  43. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/connection.ps1 +0 -0
  44. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/connection.rs +0 -0
  45. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/install.rs +0 -0
  46. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/macos.rs +0 -0
  47. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/owner.rs +0 -0
  48. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/session.rs +0 -0
  49. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop/windows.rs +0 -0
  50. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop_core.js +0 -0
  51. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/desktop_send.js +0 -0
  52. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/error.rs +0 -0
  53. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/graph.rs +0 -0
  54. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/lib.rs +0 -0
  55. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/main.rs +0 -0
  56. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/output.rs +0 -0
  57. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/render.rs +0 -0
  58. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/schema.rs +0 -0
  59. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/setup/capabilities.rs +0 -0
  60. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/setup.rs +0 -0
  61. {teams_cli-0.2.3 → teams_cli-0.2.5}/src/teams_cli_alias.rs +0 -0
  62. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/browser_lifetime.rs +0 -0
  63. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/cli.rs +0 -0
  64. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/connection.rs +0 -0
  65. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/connection.test.ps1 +0 -0
  66. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/credentials.rs +0 -0
  67. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/desktop.rs +0 -0
  68. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/desktop_scripts.rs +0 -0
  69. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/fixtures/clispec-v0.3.json +0 -0
  70. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/js/people.test.mjs +0 -0
  71. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/package_alias.rs +0 -0
  72. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/pass_credentials.sh +0 -0
  73. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/release_smoke.rs +0 -0
  74. {teams_cli-0.2.3 → teams_cli-0.2.5}/tests/wsl_credentials.test.ps1 +0 -0
  75. {teams_cli-0.2.3 → teams_cli-0.2.5}/vership.toml +0 -0
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.5](https://github.com/rvben/teams-cli/compare/v0.2.4...v0.2.5) - 2026-10-21
11
+
12
+ ### Fixed
13
+
14
+ - Update TLS certificate verification and bundled root certificates to address RUSTSEC-2026-0382 and RUSTSEC-2026-0383.
15
+ - Desktop chats whose messages Teams keeps in a secondary reply-chain store (`replychains-2`, ...) no longer look empty. All such stores are now read and merged, which also fills in member names for those 1:1 chats.
16
+ - Desktop chat messages missing from the current cache are filled in from caches the same user and tenant left under earlier display languages. Those caches no longer update, so messages edited or deleted since can appear as they were.
17
+ - In the TUI, a chat whose messages are missing now shows its latest message from the chat list and why, instead of "No messages in this chat yet." With the desktop backend it says no messages for the chat were found in the Teams cache on this PC.
18
+ - **deps**: update TLS certificate trust dependencies ([435b11c](https://github.com/rvben/teams-cli/commit/435b11c39113e165e843d73579bc28505ef05620))
19
+
20
+ ## [0.2.4](https://github.com/rvben/teams-cli/compare/v0.2.3...v0.2.4) - 2026-10-20
21
+
22
+ ### Fixed
23
+
24
+ - When Teams keeps caches for several accounts, guest organizations or display languages, the desktop backend now reads the one for the signed-in user, tenant and display language instead of refusing.
25
+
26
+ ### Changed
27
+
28
+ - Desktop errors now tell apart two cases: personal and work/school Teams both being open, which `--desktop-account` resolves, and one surface holding cached data for several accounts or organizations, which it does not.
29
+ - Windows desktop helpers use protocol 5. WSL requires the helper from the same CLI release.
30
+
10
31
  ## [0.2.3](https://github.com/rvben/teams-cli/compare/v0.2.2...v0.2.3) - 2026-10-16
11
32
 
12
33
  ### Fixed
@@ -2815,9 +2815,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
2815
2815
 
2816
2816
  [[package]]
2817
2817
  name = "rustls-webpki"
2818
- version = "0.103.15"
2818
+ version = "0.103.16"
2819
2819
  source = "registry+https://github.com/rust-lang/crates.io-index"
2820
- checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
2820
+ checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
2821
2821
  dependencies = [
2822
2822
  "aws-lc-rs",
2823
2823
  "ring",
@@ -3187,7 +3187,7 @@ dependencies = [
3187
3187
 
3188
3188
  [[package]]
3189
3189
  name = "teams-cli"
3190
- version = "0.2.3"
3190
+ version = "0.2.5"
3191
3191
  dependencies = [
3192
3192
  "assert_cmd",
3193
3193
  "base64 0.23.1",
@@ -3843,9 +3843,9 @@ dependencies = [
3843
3843
 
3844
3844
  [[package]]
3845
3845
  name = "webpki-root-certs"
3846
- version = "1.0.9"
3846
+ version = "1.0.10"
3847
3847
  source = "registry+https://github.com/rust-lang/crates.io-index"
3848
- checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
3848
+ checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
3849
3849
  dependencies = [
3850
3850
  "rustls-pki-types",
3851
3851
  ]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "teams-cli"
3
- version = "0.2.3"
3
+ version = "0.2.5"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "A Microsoft Teams CLI and TUI designed equally for humans and agents"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: teams-cli
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  License-File: LICENSE
@@ -1690,9 +1690,11 @@ mod tests {
1690
1690
  .unwrap();
1691
1691
  std::fs::set_permissions(&browser, std::fs::Permissions::from_mode(0o700)).unwrap();
1692
1692
  let started = std::time::Instant::now();
1693
- let error = browser_version_with_timeout(&browser, Duration::from_millis(100)).unwrap_err();
1693
+ // Fresh executable inspection on macOS can exceed 100 ms before the
1694
+ // script writes its PID. Allow startup while still bounding the hang.
1695
+ let error = browser_version_with_timeout(&browser, Duration::from_secs(1)).unwrap_err();
1694
1696
  assert!(error.to_string().contains("timed out"));
1695
- assert!(started.elapsed() < Duration::from_secs(2));
1697
+ assert!(started.elapsed() < Duration::from_secs(3));
1696
1698
  let pid: i32 = std::fs::read_to_string(dir.path().join("browser.pid"))
1697
1699
  .unwrap()
1698
1700
  .parse()
@@ -10,7 +10,7 @@ use crate::error::AppError;
10
10
  use serde_json::{Value, json};
11
11
  use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt};
12
12
 
13
- pub const PROTOCOL: u32 = 4;
13
+ pub const PROTOCOL: u32 = 5;
14
14
  const VERSION: &str = env!("CARGO_PKG_VERSION");
15
15
  /// The commit this binary was built from (see `build.rs`), reported in `hello` for
16
16
  /// diagnosis and never compared; empty when unknown.
@@ -7,13 +7,16 @@ async (p, select) => {
7
7
  q.onsuccess = () => resolve(q.result);
8
8
  q.onerror = () => reject(q.error);
9
9
  });
10
- const open = k => {
11
- if (!cache[k]) throw Error('layout');
10
+ const openName = name => {
12
11
  // If the cache disappeared after enumeration, abort rather than create a database.
13
- const q = indexedDB.open(cache[k]);
12
+ const q = indexedDB.open(name);
14
13
  q.onupgradeneeded = () => q.transaction.abort();
15
14
  return req(q);
16
15
  };
16
+ const open = k => {
17
+ if (!cache[k]) throw Error('layout');
18
+ return openName(cache[k]);
19
+ };
17
20
  // Missing cache timestamps can be -1. Keep sort keys valid for CLI cursors.
18
21
  const time = t => Number.isFinite(+t) && +t > 0 && +t <= 8640000000000000 ? Math.trunc(+t) : 0;
19
22
  const iso = t => time(t) ? new Date(time(t)).toISOString() : null;
@@ -64,8 +67,16 @@ async (p, select) => {
64
67
  }
65
68
  return { names, me };
66
69
  };
67
- const chain = async (db, id) => (await req(db.transaction('replychains').objectStore('replychains')
68
- .getAll(IDBKeyRange.bound([id], [id, []])))).flatMap(rc => Object.values(rc.messageMap || {}));
70
+ // Teams can split reply chains across several stores (replychains, replychains-2, ...).
71
+ // Numeric order keeps the primary store's copy of a message found in more than one.
72
+ const chain = async (db, id) => {
73
+ const rank = n => +(n.split('-')[1] || 1);
74
+ const names = [...db.objectStoreNames].filter(n => /^replychains(-\d+)?$/.test(n)).sort((a, b) => rank(a) - rank(b));
75
+ if (!names.length) throw Error('layout');
76
+ const tx = db.transaction(names);
77
+ const lists = await Promise.all(names.map(n => req(tx.objectStore(n).getAll(IDBKeyRange.bound([id], [id, []])))));
78
+ return lists.flat().flatMap(rc => Object.values((rc && rc.messageMap) || {}));
79
+ };
69
80
 
70
81
  const cdb = await open('conversation');
71
82
  let pg;
@@ -124,10 +135,24 @@ async (p, select) => {
124
135
  }
125
136
  return { me, items: chats.map(x => x.value), next: pg.next };
126
137
  }
138
+ // The current cache wins; older caches of the same account only add messages it lacks.
139
+ const messages = await chain(rdb, p.chat);
140
+ for (const name of cache.older || []) {
141
+ let db;
142
+ try {
143
+ db = await openName(name);
144
+ messages.push(...await chain(db, p.chat));
145
+ } catch (_) {
146
+ // Older caches are optional; one that vanished or changed layout is skipped.
147
+ } finally {
148
+ if (db) db.close();
149
+ }
150
+ }
127
151
  const rows = [], seen = new Set();
128
- for (const m of await chain(rdb, p.chat)) {
129
- if (system(m.messageType) || seen.has(m.id)) continue;
152
+ for (const m of messages) {
153
+ if (seen.has(m.id)) continue;
130
154
  seen.add(m.id);
155
+ if (system(m.messageType)) continue;
131
156
  rows.push({ t: time(m.originalArrivalTime), id: m.id, value: {
132
157
  id: m.id, createdDateTime: iso(m.originalArrivalTime),
133
158
  clientMessageId: typeof m.clientMessageId === 'string' ? m.clientMessageId : null,
@@ -71,7 +71,11 @@ pub fn messaging_unsupported() -> AppError {
71
71
  }
72
72
 
73
73
  fn account_selection_error() -> AppError {
74
- AppError::Desktop("Multiple Teams accounts are available; use `init --backend desktop --desktop-account personal` or `--desktop-account work-school` to select a surface. Multiple cached accounts within that surface are still unsupported".into())
74
+ AppError::Desktop("Both personal and work/school Teams are open; use `init --backend desktop --desktop-account personal` or `--desktop-account work-school` to select one".into())
75
+ }
76
+
77
+ fn cached_accounts_error() -> AppError {
78
+ AppError::Desktop("Teams holds cached data for more than one account, organization or display language, and Teams does not record which one is signed in. For a work/school account, Microsoft Graph works instead: `teams init --backend graph`".into())
75
79
  }
76
80
 
77
81
  #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -676,6 +680,7 @@ fn people_result(reply: &Value, create: bool) -> Result<Value, AppError> {
676
680
  if let Some(code) = value["error"].as_str() {
677
681
  return Err(match code {
678
682
  "account_ambiguous" => account_selection_error(),
683
+ "cache_ambiguous" => cached_accounts_error(),
679
684
  "permission_denied" => AppError::Permission("Teams policy does not allow a chat with this recipient".into()),
680
685
  "search_failed" => AppError::Desktop("Teams could not resolve this person. Check sign-in and try their full email address".into()),
681
686
  "layout" => AppError::Unsupported("This Teams version does not expose a supported recipient interface".into()),
@@ -784,6 +789,7 @@ fn send_error(code: &str) -> AppError {
784
789
  match code {
785
790
  "not_found" => AppError::NotFound("chat not found in the Teams desktop cache".into()),
786
791
  "account_ambiguous" => account_selection_error(),
792
+ "cache_ambiguous" => cached_accounts_error(),
787
793
  "unconfirmed" => AppError::Unconfirmed(
788
794
  "Teams did not confirm the message; it may still be delivered. Check the chat before sending again".into(),
789
795
  ),
@@ -894,6 +900,9 @@ fn read_page(reply: &Value) -> Result<Page, AppError> {
894
900
  if code == "account_ambiguous" {
895
901
  return Err(account_selection_error());
896
902
  }
903
+ if code == "cache_ambiguous" {
904
+ return Err(cached_accounts_error());
905
+ }
897
906
  return Err(AppError::Desktop(match (status, code) {
898
907
  (Some(status), _) => status.detail().into(),
899
908
  (None, "no_target") => {
@@ -928,6 +937,9 @@ fn read_page(reply: &Value) -> Result<Page, AppError> {
928
937
  if value["error"] == "account_ambiguous" {
929
938
  return Err(account_selection_error());
930
939
  }
940
+ if value["error"] == "cache_ambiguous" {
941
+ return Err(cached_accounts_error());
942
+ }
931
943
  let items = value["items"].as_array().ok_or_else(invalid)?.clone();
932
944
  let next_cursor = match &value["next"] {
933
945
  Value::Null => None,
@@ -1219,7 +1231,7 @@ mod tests {
1219
1231
  /// the new one; otherwise just update the expectations.
1220
1232
  #[test]
1221
1233
  fn the_protocol_is_pinned() {
1222
- assert_eq!(helper::PROTOCOL, 4);
1234
+ assert_eq!(helper::PROTOCOL, 5);
1223
1235
  let cursor = encode_cursor(&json!({"t": 1, "id": "m1"})).unwrap();
1224
1236
  assert_eq!(
1225
1237
  read_request(9222, Some(NOTES), 20, Some(&cursor)).unwrap(),
@@ -1258,14 +1270,15 @@ mod tests {
1258
1270
  .map(|b| format!("{b:02x}"))
1259
1271
  .collect::<String>()
1260
1272
  });
1261
- // Protocol 4 adds recipient operations; existing sends retain their request shapes.
1273
+ // Protocol 5 selects the signed-in cache and reports remaining ambiguity apart from surface selection.
1274
+ // Older same-account caches are optional; a helper without them reads as before.
1262
1275
  assert_eq!(
1263
1276
  scripts,
1264
1277
  [
1265
- "32ca0293d2d5",
1278
+ "9c000776f04a",
1266
1279
  "baa0bc29c44b",
1267
- "3e837cc21336",
1268
- "3d628a24b464"
1280
+ "7d2b70751d6a",
1281
+ "23b1a0e4b293"
1269
1282
  ]
1270
1283
  );
1271
1284
  }
@@ -1300,7 +1313,11 @@ mod tests {
1300
1313
  ));
1301
1314
  assert!(matches!(
1302
1315
  people_result(&page_reply(json!({"error":"account_ambiguous"})), false),
1303
- Err(AppError::Desktop(_))
1316
+ Err(AppError::Desktop(m)) if m.contains("--desktop-account")
1317
+ ));
1318
+ assert!(matches!(
1319
+ people_result(&page_reply(json!({"error":"cache_ambiguous"})), false),
1320
+ Err(AppError::Desktop(m)) if !m.contains("--desktop-account")
1304
1321
  ));
1305
1322
  for id in [
1306
1323
  "someone@example.invalid",
@@ -1469,6 +1486,7 @@ mod tests {
1469
1486
  Err(AppError::NotFound(_))
1470
1487
  ));
1471
1488
  let layout = json!({"result":{"exceptionDetails":{"exception":{"description":"Error: layout\n at x"}}}});
1489
+ // Mixed personal and work/school windows are resolved by choosing a surface.
1472
1490
  for ambiguous in [
1473
1491
  json!({"error": "account_ambiguous"}),
1474
1492
  reply(json!({"error": "account_ambiguous"})),
@@ -1480,6 +1498,20 @@ mod tests {
1480
1498
  matches!(sent_message(&ambiguous, CHAT), Err(AppError::Desktop(m)) if m.contains("--desktop-account"))
1481
1499
  );
1482
1500
  }
1501
+ // Several caches within one surface are not, so no flag is suggested.
1502
+ for ambiguous in [
1503
+ json!({"error": "cache_ambiguous"}),
1504
+ reply(json!({"error": "cache_ambiguous"})),
1505
+ ] {
1506
+ for result in [
1507
+ read_page(&ambiguous).map(|_| ()),
1508
+ sent_message(&ambiguous, CHAT).map(|_| ()),
1509
+ ] {
1510
+ assert!(
1511
+ matches!(result, Err(AppError::Desktop(m)) if m.contains("cached") && !m.contains("--desktop-account"))
1512
+ );
1513
+ }
1514
+ }
1483
1515
  assert!(matches!(read_page(&layout), Err(AppError::Desktop(m)) if m.contains("layout")));
1484
1516
  for reply in [
1485
1517
  json!({"error": "no_target"}),
@@ -0,0 +1,48 @@
1
+ // Select one account's cache databases. Several account caches can coexist in a
2
+ // storage context; their records must never be joined or sent through arbitrarily.
3
+ all => {
4
+ const candidates = kind => all.filter(db =>
5
+ typeof db.name === 'string' && db.name.includes(':' + kind + ':'));
6
+ // Teams names caches `<prefix>:<kind>:<client>:<tenant>:<user>:<locale>`. Other
7
+ // accounts, guest organizations and earlier display languages leave their own; the
8
+ // signed-in one is what Teams itself records, and anything less agreed is refused.
9
+ const signedIn = () => {
10
+ try {
11
+ const item = key => JSON.parse(localStorage.getItem(key) ?? 'null');
12
+ const user = item('tmp.auth.v1.GLOBAL.PrimaryUserId.PrimaryUserId')?.item;
13
+ const primary = item('tmp.react-web-client.cachedPrimaryUser');
14
+ if (typeof user !== 'string' || primary?.userId !== user || typeof primary.tenantId !== 'string') return null;
15
+ const locale = localStorage.getItem(`tmp.${primary.tenantId}.${user}.react-web-client.applicationLocaleCode`);
16
+ return { tenant: primary.tenantId, user, locale };
17
+ } catch {
18
+ return null;
19
+ }
20
+ };
21
+ let conversations = candidates('conversation-manager');
22
+ if (conversations.length > 1) {
23
+ const active = signedIn();
24
+ const part = (db, index) => db.name.split(':')[index];
25
+ if (active) conversations = conversations.filter(db =>
26
+ part(db, 3) === active.tenant && part(db, 4) === active.user);
27
+ if (active && conversations.length > 1) conversations = conversations.filter(db => part(db, 5) === active.locale);
28
+ if (conversations.length !== 1) return { error: 'cache_ambiguous' };
29
+ }
30
+ if (conversations.length !== 1) return { error: 'layout' };
31
+ const conversation = conversations[0].name;
32
+ const [, , , tenant, account] = conversation.split(':');
33
+ if (!account) return { error: 'layout' };
34
+ const related = kind => {
35
+ const sibling = conversation.replace(':conversation-manager:', ':' + kind + ':');
36
+ if (all.some(db => db.name === sibling)) return sibling;
37
+ const matches = candidates(kind).filter(db => db.name.split(':')[4] === account);
38
+ return matches.length === 1 ? matches[0].name : null;
39
+ };
40
+ const replies = related('replychain-manager');
41
+ // Caches the same user and tenant left under earlier display languages. Teams stopped
42
+ // updating them, so they may only fill in messages the current cache lacks.
43
+ const older = candidates('replychain-manager').map(db => db.name).filter(name => {
44
+ const part = name.split(':');
45
+ return name !== replies && part[3] === tenant && part[4] === account;
46
+ }).sort();
47
+ return { account, conversation, replies, profiles: related('profiles'), older };
48
+ }
@@ -281,7 +281,7 @@ async (p, select) => {
281
281
  } finally {
282
282
  db?.close();
283
283
  }
284
- if (own.length !== 1) return { error: own.length ? "account_ambiguous" : "layout" };
284
+ if (own.length !== 1) return { error: own.length ? "cache_ambiguous" : "layout" };
285
285
  if (own[0] === p.recipient) return {id:"48:notes",chatType:"oneOnOne",members:[{userId:own[0]}],source:"desktop"};
286
286
  const userIds = [...new Set([own[0], p.recipient])];
287
287
  const lookup = operationDoc(
@@ -532,6 +532,16 @@ impl App {
532
532
  }
533
533
  }
534
534
 
535
+ /// Why a chat shows only its latest message, said for where messages come from.
536
+ fn missing_history_note(&self) -> &'static str {
537
+ match self.backend().as_deref() {
538
+ Some(TeamsBackend::Desktop(_)) => {
539
+ "No messages for this chat were found in the Teams cache on this PC. Latest message:"
540
+ }
541
+ _ => "Only the latest message is available.",
542
+ }
543
+ }
544
+
535
545
  fn can_send(&self) -> bool {
536
546
  match &self.source {
537
547
  Source::Live { can_send, .. } => *can_send,
@@ -2030,7 +2040,21 @@ impl App {
2030
2040
  }
2031
2041
  Load::Ready => {}
2032
2042
  }
2033
- let entries = render::conversation(&thread.items, &self.view(), self.viewer.as_deref());
2043
+ let mut entries = render::conversation(&thread.items, &self.view(), self.viewer.as_deref());
2044
+ // The chat list can carry a latest message whose history was never read.
2045
+ let mut missing = false;
2046
+ if entries.is_empty()
2047
+ && thread.cursor.is_none()
2048
+ && let Some(chat) = self.chats.iter().find(|chat| chat["id"] == id.as_str())
2049
+ && chat["lastMessagePreview"].is_object()
2050
+ {
2051
+ entries = render::conversation(
2052
+ std::slice::from_ref(&chat["lastMessagePreview"]),
2053
+ &self.view(),
2054
+ self.viewer.as_deref(),
2055
+ );
2056
+ missing = !entries.is_empty();
2057
+ }
2034
2058
  let mut lines = Vec::new();
2035
2059
  if thread.loading_older {
2036
2060
  lines.push(
@@ -2050,7 +2074,12 @@ impl App {
2050
2074
  .alignment(Alignment::Center),
2051
2075
  );
2052
2076
  }
2053
- if entries.is_empty() {
2077
+ if missing {
2078
+ lines.push(Line::raw(""));
2079
+ for note in render::wrap(self.missing_history_note(), usize::from(body.width)) {
2080
+ lines.push(Line::styled(note, theme.muted()).centered());
2081
+ }
2082
+ } else if entries.is_empty() {
2054
2083
  lines.push(Line::raw(""));
2055
2084
  lines.push(Line::styled("No messages in this chat yet.", theme.muted()).centered());
2056
2085
  }
@@ -3706,6 +3735,66 @@ mod tests {
3706
3735
  App::new(source, Theme { color: false }, now)
3707
3736
  }
3708
3737
 
3738
+ #[test]
3739
+ fn a_chat_without_history_shows_its_latest_message_and_why() {
3740
+ let empty = |app: &mut App| {
3741
+ let id = app.selected_row().unwrap().id.clone();
3742
+ app.threads.insert(
3743
+ id,
3744
+ Thread {
3745
+ items: Vec::new(),
3746
+ cursor: None,
3747
+ load: Load::Ready,
3748
+ loading_older: false,
3749
+ fetching: false,
3750
+ },
3751
+ );
3752
+ frame_text(app)
3753
+ };
3754
+ let mut app = demo_app();
3755
+ let text = empty(&mut app);
3756
+ assert!(
3757
+ text.contains("Only the latest message is available."),
3758
+ "{text}"
3759
+ );
3760
+ assert!(text.contains("release candidate is green"), "{text}");
3761
+ assert!(!text.contains("No messages in this chat yet."), "{text}");
3762
+
3763
+ let now = app.now.clone();
3764
+ let mut app = live_app(false, "work");
3765
+ app.source = Source::Live {
3766
+ backend: Arc::new(TeamsBackend::Desktop(Box::new(
3767
+ crate::desktop::DesktopClient::new(19224).unwrap(),
3768
+ ))),
3769
+ profile: "work".into(),
3770
+ can_send: false,
3771
+ };
3772
+ app.apply(Outcome::Chats {
3773
+ more: false,
3774
+ result: Ok(demo_chats(&now)),
3775
+ });
3776
+ let text = empty(&mut app);
3777
+ assert!(text.contains("were found in the Teams cache"), "{text}");
3778
+ // The note wraps with the pane rather than being cut off.
3779
+ let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
3780
+ app.focus = Focus::Thread;
3781
+ terminal.draw(|frame| app.draw(frame)).unwrap();
3782
+ let narrow = buffer_text(terminal.backend().buffer());
3783
+ assert!(narrow.contains("Latest message:"), "{narrow}");
3784
+ assert!(text.contains("release candidate is green"), "{text}");
3785
+
3786
+ // Older pages may still hold messages, so nothing is claimed missing.
3787
+ let id = app.selected_row().unwrap().id.clone();
3788
+ app.threads.get_mut(&id).unwrap().cursor = Some("older".into());
3789
+ let text = frame_text(&mut app);
3790
+ assert!(text.contains("No messages in this chat yet."), "{text}");
3791
+
3792
+ // Without a preview there is nothing to show.
3793
+ app.chats[0]["lastMessagePreview"] = Value::Null;
3794
+ let text = empty(&mut app);
3795
+ assert!(text.contains("No messages in this chat yet."), "{text}");
3796
+ }
3797
+
3709
3798
  #[test]
3710
3799
  fn the_header_shows_the_profile_send_mode_freshness_and_counts() {
3711
3800
  let mut app = demo_app();
@@ -29,7 +29,7 @@ function cursor(records) {
29
29
  return q;
30
30
  }
31
31
 
32
- function fixture({ ambiguous = false, foreignOnly = false, mutate, timeout = false, core = false } = {}) {
32
+ function fixture({ ambiguous = false, foreignOnly = false, older = [], stores = {}, mutate, timeout = false, core = false } = {}) {
33
33
  const databases = new Map([
34
34
  [name('conversation-manager', 'account-a'), [conversation]],
35
35
  [name('replychain-manager', 'account-b'), [{ chat, messageMap: { b: { ...message, content: 'Wrong account' } } }]],
@@ -40,6 +40,9 @@ function fixture({ ambiguous = false, foreignOnly = false, mutate, timeout = fal
40
40
  databases.set(name('profiles', 'account-a'), [{ mri: '8:orgid:sender', displayName: 'Correct Name' }]);
41
41
  }
42
42
  if (ambiguous) databases.set(name('conversation-manager', 'account-b'), [conversation]);
43
+ for (const [locale, records] of older) databases.set(name('replychain-manager', 'account-a') + ':' + locale, records);
44
+ // Extra object stores of the current account's reply-chain database, beside its default store.
45
+ const extra = new Map([[name('replychain-manager', 'account-a'), stores]]);
43
46
  const opened = [], mutations = [];
44
47
  let inspections = 0;
45
48
  const client = { mutate: options => {
@@ -55,11 +58,15 @@ function fixture({ ambiguous = false, foreignOnly = false, mutate, timeout = fal
55
58
  open: database => {
56
59
  assert.ok(databases.has(database), `Unknown database: ${database}`);
57
60
  opened.push(database);
58
- return request({ name: database, close() {}, transaction: () => ({ objectStore: () => ({
59
- get: id => request(databases.get(database).find(record => (record.mri || record.id) === id)),
60
- getAll: range => request(databases.get(database).filter(record => !range || record.chat === range.chat)),
61
- openCursor: () => cursor(databases.get(database)),
62
- }) }) });
61
+ const named = extra.get(database) || {};
62
+ const records = store => store in named ? named[store] : databases.get(database);
63
+ return request({ name: database, close() {},
64
+ objectStoreNames: database.includes(':replychain-manager:') ? ['replychains', ...Object.keys(named)] : [],
65
+ transaction: () => ({ objectStore: store => ({
66
+ get: id => request(records(store).find(record => (record.mri || record.id) === id)),
67
+ getAll: range => request(records(store).filter(record => !range || record.chat === range.chat)),
68
+ openCursor: () => cursor(records(store)),
69
+ }) }) });
63
70
  },
64
71
  },
65
72
  IDBKeyRange: { bound: a => ({ chat: a[0] }) },
@@ -225,16 +232,109 @@ for (const query of ['chats', 'messages']) {
225
232
  });
226
233
  }
227
234
 
235
+ test('older caches of the same account only add messages the current cache lacks', async () => {
236
+ const earlier = { ...message, id: '1690000000000', originalArrivalTime: 1690000000000, content: 'Earlier' };
237
+ const f = fixture({ older: [
238
+ ['nl-nl', [{ chat, messageMap: { stale: { ...message, content: 'Stale' }, earlier } }]],
239
+ ['de-de', [{ chat: '19:other@thread.v2', messageMap: { other: { ...message, id: '1680000000000', content: 'Other chat' } } }]],
240
+ ] });
241
+ const result = await f.read();
242
+ assert.deepEqual([...result.items.map(item => item.body.content)], [message.content, 'Earlier']);
243
+ assert.ok(f.opened.every(database => !database.includes(':account-b')));
244
+ });
245
+
246
+ test('messages filed only in a later reply-chain store are read', async () => {
247
+ const f = fixture({ stores: {
248
+ 'replychains-2': [{ chat, messageMap: { a: message } }],
249
+ 'unrelated': [{ chat, messageMap: { x: { ...message, id: '1710000000000', content: 'Unrelated store' } } }],
250
+ } });
251
+ f.databases.set(name('replychain-manager', 'account-a'), []);
252
+ const result = await f.read();
253
+ assert.deepEqual([...result.items.map(item => item.body.content)], [message.content]);
254
+ });
255
+
256
+ test('messages split across reply-chain stores are merged newest first without duplicates', async () => {
257
+ const earlier = { ...message, id: '1690000000000', originalArrivalTime: 1690000000000, content: 'Earlier' };
258
+ const later = { ...message, id: '1710000000000', originalArrivalTime: 1710000000000, content: 'Later' };
259
+ const f = fixture({ stores: { 'replychains-2': [{ chat, messageMap: { a: message, earlier, later } }] } });
260
+ const result = await f.read();
261
+ assert.deepEqual([...result.items.map(item => item.body.content)], ['Later', message.content, 'Earlier']);
262
+ });
263
+
264
+ test('reply-chain stores are read in numeric order, so the primary copy of a message wins', async () => {
265
+ const f = fixture({ stores: {
266
+ 'replychains-10': [{ chat, messageMap: { a: { ...message, content: 'Tenth store' } } }],
267
+ 'replychains-2': [{ chat, messageMap: { a: { ...message, content: 'Second store' } } }],
268
+ } });
269
+ assert.deepEqual([...(await f.read()).items.map(item => item.body.content)], [message.content]);
270
+ f.databases.set(name('replychain-manager', 'account-a'), []);
271
+ assert.deepEqual([...(await f.read()).items.map(item => item.body.content)], ['Second store']);
272
+ });
273
+
274
+ test('1:1 member names come from messages in a later reply-chain store', async () => {
275
+ const one = '19:account-a_peer@unq.gbl.spaces';
276
+ const f = fixture({ stores: { 'replychains-2': [{ chat: one, messageMap: {
277
+ p: { ...message, creator: '8:orgid:peer', imDisplayName: 'Peer Name' },
278
+ } }] } });
279
+ f.databases.set(name('conversation-manager', 'account-a'), [{ ...conversation, id: one, members: [] }]);
280
+ f.databases.set(name('replychain-manager', 'account-a'), []);
281
+ const result = await f.read({ query: 'chats', chat: '' });
282
+ assert.equal(result.me, 'account-a');
283
+ assert.equal(result.items[0].members.find(m => m.userId === 'peer').displayName, 'Peer Name');
284
+ });
285
+
228
286
  test('a missing account database never falls back to another account', async () => {
229
287
  const f = fixture({ foreignOnly: true });
230
288
  await assert.rejects(f.read(), /layout/);
231
289
  assert.ok(f.opened.every(database => database.endsWith(':account-a')));
232
290
  });
233
291
 
292
+ function selectWith(names, storage = {}) {
293
+ const context = vm.createContext({ localStorage: { getItem: key => key in storage ? storage[key] : null } });
294
+ // Compare plain values: objects from another context have their own prototypes.
295
+ return JSON.parse(JSON.stringify(vm.runInContext(source('desktop_cache.js'), context)(names.map(name => ({ name })))));
296
+ }
297
+
298
+ const teams = (kind, tenant, user, locale = 'en-us') => `Teams:${kind}:react-web-client:${tenant}:${user}:${locale}`;
299
+ const signedIn = (tenant, user, locale) => ({
300
+ 'tmp.auth.v1.GLOBAL.PrimaryUserId.PrimaryUserId': JSON.stringify({ item: user }),
301
+ 'tmp.react-web-client.cachedPrimaryUser': JSON.stringify({ tenantId: tenant, userId: user }),
302
+ ...(locale && { [`tmp.${tenant}.${user}.react-web-client.applicationLocaleCode`]: locale }),
303
+ });
304
+ const caches = (tenant, user, locale) => ['conversation-manager', 'replychain-manager', 'profiles']
305
+ .map(kind => teams(kind, tenant, user, locale));
306
+
307
+ test('the signed-in user, tenant and display language select among coexisting caches', () => {
308
+ const names = [
309
+ ...caches('home', 'me', 'en-us'), ...caches('home', 'me', 'nl-nl'),
310
+ ...caches('guest', 'me-as-guest'), ...caches('home', 'colleague'),
311
+ ];
312
+ for (const [tenant, user, locale, older] of [['home', 'me', 'nl-nl', ['en-us']], ['home', 'me', 'en-us', ['nl-nl']], ['guest', 'me-as-guest', 'en-us', []]]) {
313
+ assert.deepEqual(selectWith(names, signedIn(tenant, user, locale)), {
314
+ account: user, conversation: teams('conversation-manager', tenant, user, locale),
315
+ replies: teams('replychain-manager', tenant, user, locale), profiles: teams('profiles', tenant, user, locale),
316
+ // Only the same user in the same tenant may fill in messages.
317
+ older: older.map(other => teams('replychain-manager', tenant, user, other)),
318
+ });
319
+ }
320
+ // Only one cache for the signed-in account needs no display language.
321
+ assert.equal(selectWith(names, signedIn('home', 'colleague')).conversation, teams('conversation-manager', 'home', 'colleague'));
322
+ });
323
+
324
+ test('caches are refused when Teams does not identify exactly one of them', () => {
325
+ const names = [...caches('home', 'me', 'en-us'), ...caches('home', 'me', 'nl-nl'), ...caches('home', 'colleague')];
326
+ const inconsistent = { ...signedIn('home', 'me', 'en-us'),
327
+ 'tmp.react-web-client.cachedPrimaryUser': JSON.stringify({ tenantId: 'home', userId: 'colleague' }) };
328
+ for (const storage of [{}, signedIn('home', 'me'), signedIn('home', 'me', 'de-de'), signedIn('home', 'stranger', 'en-us'), inconsistent,
329
+ { 'tmp.auth.v1.GLOBAL.PrimaryUserId.PrimaryUserId': 'not json' }]) {
330
+ assert.deepEqual(selectWith(names, storage), { error: 'cache_ambiguous' });
331
+ }
332
+ });
333
+
234
334
  test('ambiguous account caches are refused before reading or sending', async () => {
235
335
  const f = fixture({ ambiguous: true });
236
- assert.equal((await f.read()).error, 'account_ambiguous');
237
- assert.equal((await f.send()).error, 'account_ambiguous');
336
+ assert.equal((await f.read()).error, 'cache_ambiguous');
337
+ assert.equal((await f.send()).error, 'cache_ambiguous');
238
338
  assert.deepEqual(f.opened, []);
239
339
  assert.deepEqual(f.mutations, []);
240
340
  });
@@ -1,17 +0,0 @@
1
- // Select one account's cache databases. Several account caches can coexist in a
2
- // storage context; their records must never be joined or sent through arbitrarily.
3
- all => {
4
- const candidates = kind => all.filter(db =>
5
- typeof db.name === 'string' && db.name.includes(':' + kind + ':'));
6
- const conversations = candidates('conversation-manager');
7
- if (conversations.length > 1) return { error: 'account_ambiguous' };
8
- if (conversations.length !== 1) return { error: 'layout' };
9
- const conversation = conversations[0].name;
10
- const account = conversation.split(':')[4];
11
- if (!account) return { error: 'layout' };
12
- const related = kind => {
13
- const matches = candidates(kind).filter(db => db.name.split(':')[4] === account);
14
- return matches.length === 1 ? matches[0].name : null;
15
- };
16
- return { account, conversation, replies: related('replychain-manager'), profiles: related('profiles') };
17
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes