Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions che-telegram-all-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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":
Expand Down
18 changes: 16 additions & 2 deletions che-telegram-all-mcp/Sources/TelegramAllLib/TDLibClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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))
}
Original file line number Diff line number Diff line change
@@ -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)
}
}