diff --git a/che-telegram-all-mcp/CHANGELOG.md b/che-telegram-all-mcp/CHANGELOG.md index 8cc95eb..d508131 100644 --- a/che-telegram-all-mcp/CHANGELOG.md +++ b/che-telegram-all-mcp/CHANGELOG.md @@ -11,6 +11,7 @@ Parser-consistency cluster — closes the gap left by #8 (`parseMaxMessages`). - **`validateLimitCap` shared policy (#25 verify F4)**: 10_000 upper bound, parity with `validateMaxMessagesCap`. Throws `"limit exceeds 10_000 cap; got \(value). Use pagination instead of a single large request."` Applies via `parseLimit`. - **`parseMaxMessagesWithDefault(args, default:)` helper (#23 verify F2)**: variant that flows the default-value through `validateMaxMessagesCap`. Mutation-resistant: deleting the cap call on the default branch makes `testParseMaxMessagesWithDefaultAppliesCapToDefaultPath` (cap=11000 over 10_000 ceiling) fail. Replaces the earlier inline `?? 5000 + try validateMaxMessagesCap(maxMessages)` belt-and-suspenders pattern whose test was a placebo. - **`requiredInt64` + grouped Int64 parsers (#33)**: `requiredInt64(args, key)` (absent → `"X is required"`, junk → `"X must be an integer; got ..."`) plus `parseChatMessageIds` / `parseChatUserIds` / `parseChatForwardIds` / `parseSendMessageIds` structs in `HandlerArgs.swift`. These are the **testable seam** for the 20 direct `Server.swift` callsites — they were inline in `handleToolCall`, a `private` method on a TDLib-booting Server, so un-unit-testable in place. Each parser is mutation-resistantly tested in `ServerHandlerLogicTests.swift` (handler-level RED→GREEN). +- **`get_contacts` optional `limit` + `boundedContactIds` (#34)**: `get_contacts` gains an optional `limit` (default 200, 10_000 cap, parsed via the existing `parseLimit`). `boundedContactIds(_ ids:limit:)` in `TelegramAllLib` caps the one-`getUser`-per-id fan-out **client-side** — TDLib's `getContacts` has no server-side limit (unlike `getChats` / `searchChats`), so the arg alone wouldn't bound the loop. Hermetically tested in `TDLibClientContactsTests`. ### Fixed @@ -34,6 +35,7 @@ Observable on the wire for existing MCP clients (surfaced by PR #32 verify — d - **(#33) Whole-number-double ids now accepted at all Server.swift handlers (silent widening).** `chat_id: 12345.0` (and other Int64 args) are now accepted where the former non-strict `int64ArgValue` rejected them as nil → `"is required"`. Some previously-failing calls will now **succeed with real side-effects** (send/pin/edit/forward/…). This is the inverse of "junk now throws" and the more surprising wire-observable delta. - **(#33) `reply_to_message_id` junk now throws.** A non-numeric `reply_to_message_id` on `send_message` previously silently dropped the reply target (message sent with no reply); it now throws `reply_to_message_id must be an integer; got "..."`. Absent / `.null` still sends with no reply (unchanged). - **(#33) The 14 migrated multi-key handlers report the first bad field individually.** Combined messages like `"chat_id and text are required"` are replaced by per-field messages (`"chat_id is required"` / `"chat_id must be an integer; got ..."` / `"text is required"`). The combined form was itself misleading when only one field was bad (the #22 bug shape) — this is a net improvement, not just a change. Handlers outside #33 scope (e.g. `create_group`, whose only int64 is the array `user_ids`) keep their combined message. +- **(#34) `get_contacts` now bounds the contact fan-out at `limit` (default 200).** It previously fetched a user record for **every** contact (an unbounded `getUser`-per-id loop — a large address book meant N sequential TDLib round-trips). It now returns at most `limit` contacts (default 200, max 10_000). No-arg callers with more than 200 contacts get the first 200 — **silently truncated**: the bare-array output is kept for backward-compat (the CLI, E2E, and MCP handler all consume an array), so adding a truncation signal would break them. Pass a higher `limit` for more. ### Notes diff --git a/che-telegram-all-mcp/Sources/CheTelegramAllMCPCore/Server.swift b/che-telegram-all-mcp/Sources/CheTelegramAllMCPCore/Server.swift index f507046..58cfde0 100644 --- a/che-telegram-all-mcp/Sources/CheTelegramAllMCPCore/Server.swift +++ b/che-telegram-all-mcp/Sources/CheTelegramAllMCPCore/Server.swift @@ -151,7 +151,10 @@ public final class CheTelegramAllMCPServer { tool("get_contacts", description: "Get your contact list", - properties: [:], required: []), + properties: [ + "limit": prop("integer", "Max contacts to return (default 200, max 10000)"), + ], + required: []), // Chat Operations tool("get_chats", @@ -424,7 +427,8 @@ public final class CheTelegramAllMCPServer { result = try await tdlib.getUser(userId: userId) case "get_contacts": - result = try await tdlib.getContacts() + let limit = try parseLimit(args, default: 200) + result = try await tdlib.getContacts(limit: limit) // Chat Operations case "get_chats": diff --git a/che-telegram-all-mcp/Sources/TelegramAllLib/TDLibClient.swift b/che-telegram-all-mcp/Sources/TelegramAllLib/TDLibClient.swift index 46cc4b1..3995b4f 100644 --- a/che-telegram-all-mcp/Sources/TelegramAllLib/TDLibClient.swift +++ b/che-telegram-all-mcp/Sources/TelegramAllLib/TDLibClient.swift @@ -541,11 +541,14 @@ public final class TDLibClient { return toJSON(userToDict(user)) } - public func getContacts() async throws -> String { + public func getContacts(limit: Int = 200) async throws -> String { guard getAuthState() == .ready else { throw TDError.notAuthenticated } let result = try await client.getContacts() var users: [[String: Any]] = [] - for userId in result.userIds { + // #34: TDLib's getContacts has no server-side limit (unlike getChats / + // searchChats), so it returns ALL contact ids. Bound the expensive + // one-getUser-per-id fan-out client-side via `boundedContactIds`. + for userId in boundedContactIds(result.userIds, limit: limit) { let user = try await client.getUser(userId: userId) users.append(userToDict(user)) } @@ -752,3 +755,14 @@ public final class TDLibClient { return String(data: data, encoding: .utf8) ?? "{}" } } + +/// Bound the contact fan-out for #34. TDLib's `getContacts` has no server-side +/// limit (unlike `getChats` / `searchChats`), so it returns every contact id; +/// this caps the expensive one-`getUser`-per-id loop client-side. Pure + +/// testable seam (the fan-out itself needs a live TDLib connection, so this is +/// where #34's falsifiable coverage lives — see `TDLibClientContactsTests`). +/// `limit` is pre-validated by `parseLimit` (`> 0`, `≤ 10_000`) at the handler, +/// and the wrapper's own default (200) is also `> 0`, so `prefix` never traps. +internal func boundedContactIds(_ ids: [Int64], limit: Int) -> [Int64] { + Array(ids.prefix(limit)) +} diff --git a/che-telegram-all-mcp/Tests/TelegramAllLibTests/TDLibClientContactsTests.swift b/che-telegram-all-mcp/Tests/TelegramAllLibTests/TDLibClientContactsTests.swift new file mode 100644 index 0000000..bbcadf5 --- /dev/null +++ b/che-telegram-all-mcp/Tests/TelegramAllLibTests/TDLibClientContactsTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import TelegramAllLib + +/// #34: falsifiable coverage for the contact fan-out bound. +/// +/// `getContacts` loops one `getUser` per contact id over `result.userIds`. +/// TDLib's `getContacts` has no server-side limit, so the fix truncates the id +/// list client-side via `boundedContactIds(_:limit:)` before the loop. The +/// loop itself needs a live TDLib connection (untestable in place), so this +/// pure helper is where the bound is proven. A `parseLimit`-only test would be +/// a placebo — it bounds the *arg*, not the fan-out. +final class TDLibClientContactsTests: XCTestCase { + + /// More ids than the limit → truncated to the first `limit` (the bound). + /// Mutation guard: replacing `Array(ids.prefix(limit))` with `ids` (the + /// pre-#34 unbounded behavior) makes this fail with count 10 != 5. + func testBoundedContactIdsTruncatesOverLimit() { + let ids: [Int64] = Array(1...10) + let bounded = boundedContactIds(ids, limit: 5) + XCTAssertEqual(bounded, [1, 2, 3, 4, 5]) + } + + /// Fewer ids than the limit → all returned (no padding, no truncation). + func testBoundedContactIdsUnderLimitReturnsAll() { + let ids: [Int64] = [10, 20, 30] + XCTAssertEqual(boundedContactIds(ids, limit: 5), [10, 20, 30]) + } + + /// Exactly at the limit → all returned. + func testBoundedContactIdsAtLimitReturnsAll() { + let ids: [Int64] = [1, 2, 3, 4, 5] + XCTAssertEqual(boundedContactIds(ids, limit: 5), ids) + } + + /// Empty contact list → empty (no fan-out at all). + func testBoundedContactIdsEmpty() { + XCTAssertEqual(boundedContactIds([], limit: 5), []) + } + + /// The default-200 path (no-arg callers like the CLI/E2E): a list larger + /// than the default is bounded to 200. + func testBoundedContactIdsDefaultBound() { + let ids: [Int64] = Array(1...500) + XCTAssertEqual(boundedContactIds(ids, limit: 200).count, 200) + } +}