affinity-sdk 0.9.5__py3-none-any.whl

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 (92) hide show
  1. affinity/__init__.py +139 -0
  2. affinity/cli/__init__.py +7 -0
  3. affinity/cli/click_compat.py +27 -0
  4. affinity/cli/commands/__init__.py +1 -0
  5. affinity/cli/commands/_entity_files_dump.py +219 -0
  6. affinity/cli/commands/_list_entry_fields.py +41 -0
  7. affinity/cli/commands/_v1_parsing.py +77 -0
  8. affinity/cli/commands/company_cmds.py +2139 -0
  9. affinity/cli/commands/completion_cmd.py +33 -0
  10. affinity/cli/commands/config_cmds.py +540 -0
  11. affinity/cli/commands/entry_cmds.py +33 -0
  12. affinity/cli/commands/field_cmds.py +413 -0
  13. affinity/cli/commands/interaction_cmds.py +875 -0
  14. affinity/cli/commands/list_cmds.py +3152 -0
  15. affinity/cli/commands/note_cmds.py +433 -0
  16. affinity/cli/commands/opportunity_cmds.py +1174 -0
  17. affinity/cli/commands/person_cmds.py +1980 -0
  18. affinity/cli/commands/query_cmd.py +444 -0
  19. affinity/cli/commands/relationship_strength_cmds.py +62 -0
  20. affinity/cli/commands/reminder_cmds.py +595 -0
  21. affinity/cli/commands/resolve_url_cmd.py +127 -0
  22. affinity/cli/commands/session_cmds.py +84 -0
  23. affinity/cli/commands/task_cmds.py +110 -0
  24. affinity/cli/commands/version_cmd.py +29 -0
  25. affinity/cli/commands/whoami_cmd.py +36 -0
  26. affinity/cli/config.py +108 -0
  27. affinity/cli/context.py +749 -0
  28. affinity/cli/csv_utils.py +195 -0
  29. affinity/cli/date_utils.py +42 -0
  30. affinity/cli/decorators.py +77 -0
  31. affinity/cli/errors.py +28 -0
  32. affinity/cli/field_utils.py +355 -0
  33. affinity/cli/formatters.py +551 -0
  34. affinity/cli/help_json.py +283 -0
  35. affinity/cli/logging.py +100 -0
  36. affinity/cli/main.py +261 -0
  37. affinity/cli/options.py +53 -0
  38. affinity/cli/paths.py +32 -0
  39. affinity/cli/progress.py +183 -0
  40. affinity/cli/query/__init__.py +163 -0
  41. affinity/cli/query/aggregates.py +357 -0
  42. affinity/cli/query/dates.py +194 -0
  43. affinity/cli/query/exceptions.py +147 -0
  44. affinity/cli/query/executor.py +1236 -0
  45. affinity/cli/query/filters.py +248 -0
  46. affinity/cli/query/models.py +333 -0
  47. affinity/cli/query/output.py +331 -0
  48. affinity/cli/query/parser.py +619 -0
  49. affinity/cli/query/planner.py +430 -0
  50. affinity/cli/query/progress.py +270 -0
  51. affinity/cli/query/schema.py +439 -0
  52. affinity/cli/render.py +1589 -0
  53. affinity/cli/resolve.py +222 -0
  54. affinity/cli/resolvers.py +249 -0
  55. affinity/cli/results.py +308 -0
  56. affinity/cli/runner.py +218 -0
  57. affinity/cli/serialization.py +65 -0
  58. affinity/cli/session_cache.py +276 -0
  59. affinity/cli/types.py +70 -0
  60. affinity/client.py +771 -0
  61. affinity/clients/__init__.py +19 -0
  62. affinity/clients/http.py +3664 -0
  63. affinity/clients/pipeline.py +165 -0
  64. affinity/compare.py +501 -0
  65. affinity/downloads.py +114 -0
  66. affinity/exceptions.py +615 -0
  67. affinity/filters.py +1128 -0
  68. affinity/hooks.py +198 -0
  69. affinity/inbound_webhooks.py +302 -0
  70. affinity/models/__init__.py +163 -0
  71. affinity/models/entities.py +798 -0
  72. affinity/models/pagination.py +513 -0
  73. affinity/models/rate_limit_snapshot.py +48 -0
  74. affinity/models/secondary.py +413 -0
  75. affinity/models/types.py +663 -0
  76. affinity/policies.py +40 -0
  77. affinity/progress.py +22 -0
  78. affinity/py.typed +0 -0
  79. affinity/services/__init__.py +42 -0
  80. affinity/services/companies.py +1286 -0
  81. affinity/services/lists.py +1892 -0
  82. affinity/services/opportunities.py +1330 -0
  83. affinity/services/persons.py +1348 -0
  84. affinity/services/rate_limits.py +173 -0
  85. affinity/services/tasks.py +193 -0
  86. affinity/services/v1_only.py +2445 -0
  87. affinity/types.py +83 -0
  88. affinity_sdk-0.9.5.dist-info/METADATA +622 -0
  89. affinity_sdk-0.9.5.dist-info/RECORD +92 -0
  90. affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
  91. affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
  92. affinity_sdk-0.9.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,875 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+ from contextlib import ExitStack
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timedelta, timezone
9
+
10
+ from rich.console import Console
11
+ from rich.progress import BarColumn, Progress, TaskID, TextColumn, TimeElapsedColumn
12
+
13
+ from affinity.exceptions import AffinityError
14
+ from affinity.models.secondary import Interaction, InteractionCreate, InteractionUpdate
15
+ from affinity.models.types import InteractionDirection, InteractionType
16
+ from affinity.types import CompanyId, InteractionId, OpportunityId, PersonId
17
+
18
+ from ..click_compat import RichCommand, RichGroup, click
19
+ from ..context import CLIContext
20
+ from ..csv_utils import write_csv_to_stdout
21
+ from ..date_utils import ChunkedFetchResult, chunk_date_range
22
+ from ..decorators import category, destructive
23
+ from ..errors import CLIError
24
+ from ..options import output_options
25
+ from ..results import CommandContext, DateRange, ResultSummary
26
+ from ..runner import CommandOutput, run_command
27
+ from ._v1_parsing import parse_choice, parse_iso_datetime
28
+
29
+
30
+ @click.group(name="interaction", cls=RichGroup)
31
+ def interaction_group() -> None:
32
+ """Interaction commands."""
33
+
34
+
35
+ _INTERACTION_TYPE_MAP = {
36
+ "meeting": InteractionType.MEETING,
37
+ "call": InteractionType.CALL,
38
+ "chat-message": InteractionType.CHAT_MESSAGE,
39
+ "chat": InteractionType.CHAT_MESSAGE,
40
+ "email": InteractionType.EMAIL,
41
+ }
42
+
43
+ _INTERACTION_DIRECTION_MAP = {
44
+ "outgoing": InteractionDirection.OUTGOING,
45
+ "incoming": InteractionDirection.INCOMING,
46
+ }
47
+
48
+ # Canonical types for --type all expansion and output
49
+ _CANONICAL_TYPES = ["call", "chat-message", "email", "meeting"]
50
+
51
+ # Accepted types includes aliases (e.g., "chat" for "chat-message")
52
+ _ACCEPTED_TYPES = sorted(_INTERACTION_TYPE_MAP.keys())
53
+
54
+
55
+ @dataclass
56
+ class TypeStats:
57
+ """Per-type statistics for multi-type fetch."""
58
+
59
+ count: int
60
+ chunks_processed: int
61
+
62
+
63
+ @dataclass
64
+ class MultiTypeFetchResult:
65
+ """Result from multi-type interaction fetching."""
66
+
67
+ interactions: list[Interaction]
68
+ type_stats: dict[str, TypeStats]
69
+
70
+
71
+ @dataclass
72
+ class _NDJSONProgress:
73
+ """NDJSON progress emitter for non-TTY environments (MCP consumption)."""
74
+
75
+ enabled: bool = False
76
+ # Rate limit at 0.65s (matches ProgressManager) to stay under mcp-bash 100/min limit
77
+ _min_interval: float = 0.65
78
+ _last_emit_time: float = field(default_factory=lambda: float("-inf"))
79
+
80
+ def emit(
81
+ self,
82
+ message: str,
83
+ *,
84
+ current: int | None = None,
85
+ total: int | None = None,
86
+ force: bool = False,
87
+ ) -> None:
88
+ """Emit NDJSON progress to stderr for MCP consumption.
89
+
90
+ Args:
91
+ message: Human-readable progress message.
92
+ current: Current count (e.g., interactions fetched so far).
93
+ total: Total count if known (None for indeterminate).
94
+ force: Bypass rate limiting (for final summary).
95
+ """
96
+ if not self.enabled:
97
+ return
98
+
99
+ # Rate limit (unless forced)
100
+ now = time.monotonic()
101
+ if not force and now - self._last_emit_time < self._min_interval:
102
+ return
103
+ self._last_emit_time = now
104
+
105
+ # Compute percent if both current and total are known
106
+ percent = (current * 100 // total) if (current is not None and total) else None
107
+
108
+ obj: dict[str, int | str | None] = {
109
+ "type": "progress",
110
+ "progress": percent,
111
+ "message": message,
112
+ }
113
+ if current is not None:
114
+ obj["current"] = current
115
+ if total is not None:
116
+ obj["total"] = total
117
+
118
+ # flush=True is CRITICAL: Python buffers stderr when not a TTY
119
+ print(json.dumps(obj), file=sys.stderr, flush=True)
120
+
121
+
122
+ def _resolve_types(interaction_types: tuple[str, ...]) -> list[str]:
123
+ """Expand 'all' and deduplicate by canonical type.
124
+
125
+ Handles aliases: 'chat' → 'chat-message'
126
+ Returns canonical type names only.
127
+ """
128
+ if "all" in interaction_types:
129
+ return _CANONICAL_TYPES.copy()
130
+
131
+ # Deduplicate by resolved enum (handles chat vs chat-message)
132
+ seen_enums: set[InteractionType] = set()
133
+ result: list[str] = []
134
+ for t in interaction_types:
135
+ enum_val = _INTERACTION_TYPE_MAP[t]
136
+ if enum_val not in seen_enums:
137
+ seen_enums.add(enum_val)
138
+ # Use canonical name (e.g., chat → chat-message)
139
+ canonical = InteractionType(enum_val).name.lower().replace("_", "-")
140
+ result.append(canonical)
141
+ return result
142
+
143
+
144
+ def _interaction_payload(interaction: Interaction) -> dict[str, object]:
145
+ # Convert enum values back to names for CLI display
146
+ type_name = InteractionType(interaction.type).name.lower().replace("_", "-")
147
+ direction_name = (
148
+ InteractionDirection(interaction.direction).name.lower()
149
+ if interaction.direction is not None
150
+ else None
151
+ )
152
+ return {
153
+ "id": int(interaction.id),
154
+ "type": type_name,
155
+ "date": interaction.date,
156
+ "direction": direction_name,
157
+ "title": interaction.title,
158
+ "subject": interaction.subject,
159
+ "startTime": interaction.start_time,
160
+ "endTime": interaction.end_time,
161
+ "personIds": [int(p.id) for p in interaction.persons],
162
+ "attendees": interaction.attendees,
163
+ "notes": [int(n) for n in interaction.notes],
164
+ }
165
+
166
+
167
+ def _resolve_date_range(
168
+ after: str | None,
169
+ before: str | None,
170
+ days: int | None,
171
+ ) -> tuple[datetime, datetime]:
172
+ """Resolve date flags to start/end datetimes.
173
+
174
+ If neither --days nor --after is specified, defaults to "all time"
175
+ (starting from 2010-01-01, which predates all possible Affinity data).
176
+ """
177
+ now = datetime.now(timezone.utc)
178
+
179
+ # Mutual exclusion
180
+ if days is not None and after is not None:
181
+ raise CLIError(
182
+ "--days and --after are mutually exclusive.",
183
+ error_type="usage_error",
184
+ exit_code=2,
185
+ )
186
+
187
+ # Resolve start
188
+ if days is not None:
189
+ start = now - timedelta(days=days)
190
+ elif after is not None:
191
+ # parse_iso_datetime returns UTC-aware datetime
192
+ # (naive strings interpreted as local time, then converted to UTC)
193
+ start = parse_iso_datetime(after, label="after")
194
+ else:
195
+ # Default: all time (Affinity founded 2014, so 2010 predates all possible data)
196
+ # Using a fixed date rather than datetime.min avoids timezone edge cases
197
+ start = datetime(2010, 1, 1, tzinfo=timezone.utc)
198
+
199
+ # Resolve end (parse_iso_datetime returns UTC-aware datetime)
200
+ end = parse_iso_datetime(before, label="before") if before is not None else now
201
+
202
+ # Validate
203
+ if start >= end:
204
+ raise CLIError(
205
+ f"Start date ({start.date()}) must be before end date ({end.date()}).",
206
+ error_type="usage_error",
207
+ exit_code=2,
208
+ )
209
+
210
+ return start, end
211
+
212
+
213
+ def _fetch_interactions_chunked(
214
+ client: object, # Affinity client (typed as object to avoid import cycle)
215
+ *,
216
+ interaction_type: InteractionType,
217
+ start: datetime,
218
+ end: datetime,
219
+ person_id: PersonId | None,
220
+ company_id: CompanyId | None,
221
+ opportunity_id: OpportunityId | None,
222
+ page_size: int | None,
223
+ max_results: int | None,
224
+ progress: Progress | None,
225
+ task_id: TaskID | None,
226
+ suppress_chunk_description: bool = False,
227
+ progress_offset: int = 0,
228
+ ) -> ChunkedFetchResult:
229
+ """
230
+ Fetch interactions across date chunks.
231
+
232
+ Returns ChunkedFetchResult with interactions and chunk count for metadata.
233
+
234
+ Args:
235
+ suppress_chunk_description: If True, don't update progress description with
236
+ chunk info (used in multi-type mode where outer loop controls description).
237
+ progress_offset: Offset to add to progress counts (for cumulative totals
238
+ in multi-type mode).
239
+
240
+ Note: Relies on API using exclusive end_time boundary.
241
+ If an interaction has timestamp exactly at chunk boundary,
242
+ it will appear in the later chunk (not both).
243
+ """
244
+ chunks = list(chunk_date_range(start, end))
245
+ total_chunks = len(chunks)
246
+ results: list[Interaction] = []
247
+ chunks_processed = 0
248
+
249
+ for chunk_idx, (chunk_start, chunk_end) in enumerate(chunks, 1):
250
+ chunks_processed = chunk_idx
251
+
252
+ # Update progress description with chunk info (suppressed in multi-type mode)
253
+ if not suppress_chunk_description and progress and task_id is not None:
254
+ desc = f"{chunk_start.date()} - {chunk_end.date()} ({chunk_idx}/{total_chunks})"
255
+ progress.update(task_id, description=desc)
256
+
257
+ # Paginate within chunk
258
+ page_token: str | None = None
259
+ while True:
260
+ try:
261
+ page = client.interactions.list( # type: ignore[attr-defined]
262
+ type=interaction_type,
263
+ start_time=chunk_start,
264
+ end_time=chunk_end,
265
+ person_id=person_id,
266
+ company_id=company_id,
267
+ opportunity_id=opportunity_id,
268
+ page_size=page_size,
269
+ page_token=page_token,
270
+ )
271
+ except AffinityError as e:
272
+ raise CLIError(
273
+ f"Failed on chunk {chunk_idx}/{total_chunks} "
274
+ f"({chunk_start.date()} \u2192 {chunk_end.date()}): {e}",
275
+ error_type="api_error",
276
+ exit_code=1,
277
+ ) from e
278
+
279
+ for interaction in page.data:
280
+ results.append(interaction)
281
+ if progress and task_id is not None:
282
+ progress.update(task_id, completed=progress_offset + len(results))
283
+
284
+ # Check max_results limit
285
+ if max_results is not None and len(results) >= max_results:
286
+ return ChunkedFetchResult(
287
+ interactions=results[:max_results],
288
+ chunks_processed=chunks_processed,
289
+ )
290
+
291
+ page_token = page.next_page_token
292
+ if not page_token:
293
+ break
294
+
295
+ return ChunkedFetchResult(
296
+ interactions=results,
297
+ chunks_processed=chunks_processed,
298
+ )
299
+
300
+
301
+ def _fetch_interactions_multi_type(
302
+ client: object, # Affinity client
303
+ *,
304
+ types: list[str],
305
+ start: datetime,
306
+ end: datetime,
307
+ person_id: PersonId | None,
308
+ company_id: CompanyId | None,
309
+ opportunity_id: OpportunityId | None,
310
+ page_size: int | None,
311
+ progress: Progress | None,
312
+ task_id: TaskID | None,
313
+ ndjson_progress: _NDJSONProgress | None = None,
314
+ ) -> MultiTypeFetchResult:
315
+ """Fetch interactions across multiple types, merging results.
316
+
317
+ Note: max_results is NOT applied here - it's applied after sorting in the caller.
318
+ This ensures correct "most recent N" semantics across types.
319
+ """
320
+ all_interactions: list[Interaction] = []
321
+ type_stats: dict[str, TypeStats] = {}
322
+ is_multi_type = len(types) > 1
323
+ cumulative_count = 0
324
+
325
+ for type_idx, itype in enumerate(types):
326
+ # Build progress message for this type
327
+ desc = f"{itype} ({type_idx + 1}/{len(types)})" if is_multi_type else f"Fetching {itype}"
328
+
329
+ # Update Rich progress (TTY mode)
330
+ if progress and task_id is not None:
331
+ progress.update(task_id, description=desc, completed=cumulative_count)
332
+
333
+ # Emit NDJSON progress (non-TTY mode for MCP)
334
+ if ndjson_progress:
335
+ ndjson_progress.emit(desc, current=cumulative_count)
336
+
337
+ # Fetch this type completely (no max_results - applied after sorting)
338
+ result = _fetch_interactions_chunked(
339
+ client,
340
+ interaction_type=_INTERACTION_TYPE_MAP[itype],
341
+ start=start,
342
+ end=end,
343
+ person_id=person_id,
344
+ company_id=company_id,
345
+ opportunity_id=opportunity_id,
346
+ page_size=page_size,
347
+ max_results=None, # Fetch all - truncate after sorting
348
+ progress=progress,
349
+ task_id=task_id,
350
+ suppress_chunk_description=is_multi_type,
351
+ progress_offset=cumulative_count,
352
+ )
353
+
354
+ all_interactions.extend(result.interactions)
355
+ cumulative_count += len(result.interactions)
356
+ type_stats[itype] = TypeStats(
357
+ count=len(result.interactions),
358
+ chunks_processed=result.chunks_processed,
359
+ )
360
+
361
+ # Ensure all requested types appear in stats (even with 0 count)
362
+ for itype in types:
363
+ if itype not in type_stats:
364
+ type_stats[itype] = TypeStats(count=0, chunks_processed=0)
365
+
366
+ return MultiTypeFetchResult(
367
+ interactions=all_interactions,
368
+ type_stats=type_stats,
369
+ )
370
+
371
+
372
+ @category("read")
373
+ @interaction_group.command(name="ls", cls=RichCommand)
374
+ @click.option(
375
+ "--type",
376
+ "-t",
377
+ "interaction_types",
378
+ type=click.Choice([*_ACCEPTED_TYPES, "all"]),
379
+ multiple=True,
380
+ required=True,
381
+ help="Interaction type(s): call, chat-message, email, meeting, or 'all'. Repeatable.",
382
+ )
383
+ @click.option(
384
+ "--after",
385
+ type=str,
386
+ default=None,
387
+ help="Start date (ISO-8601). Mutually exclusive with --days.",
388
+ )
389
+ @click.option(
390
+ "--before",
391
+ type=str,
392
+ default=None,
393
+ help="End date (ISO-8601). Default: now.",
394
+ )
395
+ @click.option(
396
+ "--days",
397
+ "-d",
398
+ type=int,
399
+ default=None,
400
+ help="Fetch last N days. Mutually exclusive with --after.",
401
+ )
402
+ @click.option("--person-id", type=int, default=None, help="Filter by person id.")
403
+ @click.option("--company-id", type=int, default=None, help="Filter by company id.")
404
+ @click.option("--opportunity-id", type=int, default=None, help="Filter by opportunity id.")
405
+ @click.option("--page-size", "-s", type=int, default=None, help="Page size (max 500).")
406
+ @click.option(
407
+ "--max-results", "--limit", "-n", type=int, default=None, help="Stop after N results total."
408
+ )
409
+ @click.option("--csv", "csv_flag", is_flag=True, help="Output as CSV to stdout.")
410
+ @click.option("--csv-bom", is_flag=True, help="Add UTF-8 BOM for Excel compatibility.")
411
+ @output_options
412
+ @click.pass_obj
413
+ def interaction_ls(
414
+ ctx: CLIContext,
415
+ *,
416
+ interaction_types: tuple[str, ...],
417
+ after: str | None,
418
+ before: str | None,
419
+ days: int | None,
420
+ person_id: int | None,
421
+ company_id: int | None,
422
+ opportunity_id: int | None,
423
+ page_size: int | None,
424
+ max_results: int | None,
425
+ csv_flag: bool,
426
+ csv_bom: bool,
427
+ ) -> None:
428
+ """List interactions with automatic date range handling.
429
+
430
+ Requires --type (repeatable) and one entity selector (--person-id, --company-id, or
431
+ --opportunity-id). Multiple types can be specified with -t/--type flags, or use
432
+ --type all to fetch all interaction types.
433
+
434
+ Date range defaults to all time if not specified. Use --days (relative) or
435
+ --after/--before (absolute) to limit the range. Ranges exceeding 1 year are
436
+ automatically split into chunks.
437
+
438
+ Examples:
439
+
440
+ # All interactions ever with a company
441
+ xaffinity interaction ls --type all --company-id 456
442
+
443
+ # Last 30 days of meetings with a person
444
+ xaffinity interaction ls --type meeting --person-id 123 --days 30
445
+
446
+ # Emails and meetings from a specific date range
447
+ xaffinity interaction ls -t email -t meeting --company-id 456 --after 2023-01-01
448
+
449
+ # Last 2 years of calls (auto-chunked)
450
+ xaffinity interaction ls -t call --person-id 789 --days 730
451
+ """
452
+
453
+ def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
454
+ # Validate entity selector
455
+ entity_count = sum(1 for x in [person_id, company_id, opportunity_id] if x is not None)
456
+ if entity_count == 0:
457
+ raise CLIError(
458
+ "Specify --person-id, --company-id, or --opportunity-id.",
459
+ error_type="usage_error",
460
+ exit_code=2,
461
+ )
462
+ if entity_count > 1:
463
+ raise CLIError(
464
+ "Only one entity selector allowed.",
465
+ error_type="usage_error",
466
+ exit_code=2,
467
+ )
468
+
469
+ # Resolve types (expand 'all', deduplicate aliases)
470
+ resolved_types = _resolve_types(interaction_types)
471
+
472
+ # Resolve dates (validates mutual exclusion, defaults to all-time)
473
+ start, end = _resolve_date_range(after, before, days)
474
+
475
+ # CSV mutual exclusion
476
+ if csv_flag and ctx.output == "json":
477
+ raise CLIError(
478
+ "--csv and --json are mutually exclusive.",
479
+ exit_code=2,
480
+ error_type="usage_error",
481
+ )
482
+
483
+ client = ctx.get_client(warnings=warnings)
484
+
485
+ # Determine if Rich progress (TTY) should be shown
486
+ show_rich_progress = (
487
+ ctx.progress != "never"
488
+ and not ctx.quiet
489
+ and (ctx.progress == "always" or sys.stderr.isatty())
490
+ )
491
+
492
+ # NDJSON progress for non-TTY environments (MCP consumption)
493
+ ndjson_progress = _NDJSONProgress(
494
+ enabled=(ctx.progress != "never" and not ctx.quiet and not sys.stderr.isatty())
495
+ )
496
+
497
+ with ExitStack() as stack:
498
+ progress: Progress | None = None
499
+ task_id: TaskID | None = None
500
+
501
+ if show_rich_progress:
502
+ progress = stack.enter_context(
503
+ Progress(
504
+ TextColumn("{task.description}"),
505
+ BarColumn(),
506
+ TextColumn("{task.completed} rows"),
507
+ TimeElapsedColumn(),
508
+ console=Console(file=sys.stderr),
509
+ transient=True,
510
+ )
511
+ )
512
+ task_id = progress.add_task("Fetching interactions", total=None)
513
+
514
+ fetch_result = _fetch_interactions_multi_type(
515
+ client,
516
+ types=resolved_types,
517
+ start=start,
518
+ end=end,
519
+ person_id=PersonId(person_id) if person_id else None,
520
+ company_id=CompanyId(company_id) if company_id else None,
521
+ opportunity_id=OpportunityId(opportunity_id) if opportunity_id else None,
522
+ page_size=page_size,
523
+ progress=progress,
524
+ task_id=task_id,
525
+ ndjson_progress=ndjson_progress,
526
+ )
527
+
528
+ # Emit final summary (NDJSON mode, forced to bypass rate limit)
529
+ total_count = sum(s.count for s in fetch_result.type_stats.values())
530
+ types_with_data = sum(1 for s in fetch_result.type_stats.values() if s.count > 0)
531
+ if types_with_data > 1:
532
+ summary_msg = f"{total_count} interactions across {types_with_data} types"
533
+ else:
534
+ summary_msg = f"{total_count} interactions"
535
+ ndjson_progress.emit(summary_msg, current=total_count, force=True)
536
+
537
+ # Sort by date descending, then by type name, then by id for stability
538
+ sorted_interactions = sorted(
539
+ fetch_result.interactions,
540
+ key=lambda i: (
541
+ i.date or datetime.min.replace(tzinfo=timezone.utc),
542
+ InteractionType(i.type).name, # Alphabetical: CALL < CHAT_MESSAGE < EMAIL < MEETING
543
+ i.id,
544
+ ),
545
+ reverse=True,
546
+ )
547
+
548
+ # Apply max_results AFTER sorting (correct "most recent N" semantics)
549
+ if max_results and len(sorted_interactions) > max_results:
550
+ sorted_interactions = sorted_interactions[:max_results]
551
+ warnings.append(f"Results limited to {max_results}. Remove --max-results for all.")
552
+
553
+ # Convert to output format
554
+ results = [_interaction_payload(i) for i in sorted_interactions]
555
+
556
+ # CSV output
557
+ if csv_flag:
558
+ fieldnames = list(results[0].keys()) if results else []
559
+ write_csv_to_stdout(rows=results, fieldnames=fieldnames, bom=csv_bom)
560
+ sys.exit(0)
561
+
562
+ # Build type breakdown for summary (only types with results)
563
+ type_breakdown = {
564
+ itype: stats.count
565
+ for itype, stats in fetch_result.type_stats.items()
566
+ if stats.count > 0
567
+ }
568
+ total_chunks = sum(stats.chunks_processed for stats in fetch_result.type_stats.values())
569
+
570
+ # Build context - types is always an array
571
+ cmd_context = CommandContext(
572
+ name="interaction ls",
573
+ inputs={},
574
+ modifiers={
575
+ "types": resolved_types, # Always array, even for single type
576
+ "start": start.isoformat(),
577
+ "end": end.isoformat(),
578
+ **({"personId": person_id} if person_id else {}),
579
+ **({"companyId": company_id} if company_id else {}),
580
+ **({"opportunityId": opportunity_id} if opportunity_id else {}),
581
+ },
582
+ )
583
+
584
+ # Build summary for footer display
585
+ summary = ResultSummary(
586
+ total_rows=len(results),
587
+ date_range=DateRange(start=start, end=end),
588
+ type_breakdown=type_breakdown if type_breakdown else None,
589
+ chunks_processed=total_chunks if total_chunks > 0 else None,
590
+ )
591
+
592
+ return CommandOutput(
593
+ data=results, # Direct array, not wrapped
594
+ context=cmd_context,
595
+ summary=summary,
596
+ api_called=True,
597
+ )
598
+
599
+ run_command(ctx, command="interaction ls", fn=fn)
600
+
601
+
602
+ @category("read")
603
+ @interaction_group.command(name="get", cls=RichCommand)
604
+ @click.argument("interaction_id", type=int)
605
+ @click.option(
606
+ "--type",
607
+ "-t",
608
+ "interaction_type",
609
+ type=click.Choice(sorted(_INTERACTION_TYPE_MAP.keys())),
610
+ required=True,
611
+ help="Interaction type (required by API).",
612
+ )
613
+ @output_options
614
+ @click.pass_obj
615
+ def interaction_get(ctx: CLIContext, interaction_id: int, *, interaction_type: str) -> None:
616
+ """Get an interaction by id.
617
+
618
+ The --type flag is required because the Affinity API stores interactions
619
+ in type-specific tables.
620
+
621
+ Examples:
622
+
623
+ - `xaffinity interaction get 123 --type meeting`
624
+ - `xaffinity interaction get 456 -t email`
625
+ """
626
+
627
+ def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
628
+ parsed_type = parse_choice(
629
+ interaction_type,
630
+ _INTERACTION_TYPE_MAP,
631
+ label="interaction type",
632
+ )
633
+ if parsed_type is None:
634
+ raise CLIError("Missing interaction type.", error_type="usage_error", exit_code=2)
635
+ client = ctx.get_client(warnings=warnings)
636
+ interaction = client.interactions.get(InteractionId(interaction_id), parsed_type)
637
+
638
+ cmd_context = CommandContext(
639
+ name="interaction get",
640
+ inputs={"interactionId": interaction_id},
641
+ modifiers={"type": interaction_type},
642
+ )
643
+
644
+ return CommandOutput(
645
+ data={"interaction": _interaction_payload(interaction)},
646
+ context=cmd_context,
647
+ api_called=True,
648
+ )
649
+
650
+ run_command(ctx, command="interaction get", fn=fn)
651
+
652
+
653
+ @category("write")
654
+ @interaction_group.command(name="create", cls=RichCommand)
655
+ @click.option(
656
+ "--type",
657
+ "-t",
658
+ "interaction_type",
659
+ type=click.Choice(sorted(_INTERACTION_TYPE_MAP.keys())),
660
+ required=True,
661
+ help="Interaction type (required).",
662
+ )
663
+ @click.option("--person-id", "person_ids", multiple=True, type=int, help="Person id.")
664
+ @click.option("--content", type=str, required=True, help="Interaction content.")
665
+ @click.option("--date", type=str, required=True, help="Interaction date (ISO-8601).")
666
+ @click.option(
667
+ "--direction",
668
+ type=click.Choice(sorted(_INTERACTION_DIRECTION_MAP.keys())),
669
+ default=None,
670
+ help="Direction (incoming, outgoing).",
671
+ )
672
+ @output_options
673
+ @click.pass_obj
674
+ def interaction_create(
675
+ ctx: CLIContext,
676
+ *,
677
+ interaction_type: str,
678
+ person_ids: tuple[int, ...],
679
+ content: str,
680
+ date: str,
681
+ direction: str | None,
682
+ ) -> None:
683
+ """Create an interaction."""
684
+
685
+ def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
686
+ if not person_ids:
687
+ raise CLIError(
688
+ "At least one --person-id is required.",
689
+ error_type="usage_error",
690
+ exit_code=2,
691
+ )
692
+
693
+ parsed_type = parse_choice(
694
+ interaction_type,
695
+ _INTERACTION_TYPE_MAP,
696
+ label="interaction type",
697
+ )
698
+ if parsed_type is None:
699
+ raise CLIError("Missing interaction type.", error_type="usage_error", exit_code=2)
700
+ parsed_direction = parse_choice(direction, _INTERACTION_DIRECTION_MAP, label="direction")
701
+ date_value = parse_iso_datetime(date, label="date")
702
+
703
+ client = ctx.get_client(warnings=warnings)
704
+ interaction = client.interactions.create(
705
+ InteractionCreate(
706
+ type=parsed_type,
707
+ person_ids=[PersonId(pid) for pid in person_ids],
708
+ content=content,
709
+ date=date_value,
710
+ direction=parsed_direction,
711
+ )
712
+ )
713
+
714
+ # Build CommandContext for interaction create
715
+ ctx_modifiers: dict[str, object] = {
716
+ "type": interaction_type,
717
+ "personIds": list(person_ids),
718
+ "date": date,
719
+ }
720
+ if direction:
721
+ ctx_modifiers["direction"] = direction
722
+
723
+ cmd_context = CommandContext(
724
+ name="interaction create",
725
+ inputs={"type": interaction_type},
726
+ modifiers=ctx_modifiers,
727
+ )
728
+
729
+ return CommandOutput(
730
+ data={"interaction": _interaction_payload(interaction)},
731
+ context=cmd_context,
732
+ api_called=True,
733
+ )
734
+
735
+ run_command(ctx, command="interaction create", fn=fn)
736
+
737
+
738
+ @category("write")
739
+ @interaction_group.command(name="update", cls=RichCommand)
740
+ @click.argument("interaction_id", type=int)
741
+ @click.option(
742
+ "--type",
743
+ "-t",
744
+ "interaction_type",
745
+ type=click.Choice(sorted(_INTERACTION_TYPE_MAP.keys())),
746
+ required=True,
747
+ help="Interaction type (required by API).",
748
+ )
749
+ @click.option("--person-id", "person_ids", multiple=True, type=int, help="Person id.")
750
+ @click.option("--content", type=str, default=None, help="Interaction content.")
751
+ @click.option("--date", type=str, default=None, help="Interaction date (ISO-8601).")
752
+ @click.option(
753
+ "--direction",
754
+ type=click.Choice(sorted(_INTERACTION_DIRECTION_MAP.keys())),
755
+ default=None,
756
+ help="Direction (incoming, outgoing).",
757
+ )
758
+ @output_options
759
+ @click.pass_obj
760
+ def interaction_update(
761
+ ctx: CLIContext,
762
+ interaction_id: int,
763
+ *,
764
+ interaction_type: str,
765
+ person_ids: tuple[int, ...],
766
+ content: str | None,
767
+ date: str | None,
768
+ direction: str | None,
769
+ ) -> None:
770
+ """Update an interaction."""
771
+
772
+ def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
773
+ parsed_type = parse_choice(
774
+ interaction_type,
775
+ _INTERACTION_TYPE_MAP,
776
+ label="interaction type",
777
+ )
778
+ if parsed_type is None:
779
+ raise CLIError("Missing interaction type.", error_type="usage_error", exit_code=2)
780
+
781
+ parsed_direction = parse_choice(direction, _INTERACTION_DIRECTION_MAP, label="direction")
782
+ date_value = parse_iso_datetime(date, label="date") if date else None
783
+
784
+ if not (person_ids or content or date_value or parsed_direction is not None):
785
+ raise CLIError(
786
+ "Provide at least one field to update.",
787
+ error_type="usage_error",
788
+ exit_code=2,
789
+ hint="Use --person-id, --content, --date, or --direction.",
790
+ )
791
+
792
+ client = ctx.get_client(warnings=warnings)
793
+ interaction = client.interactions.update(
794
+ InteractionId(interaction_id),
795
+ parsed_type,
796
+ InteractionUpdate(
797
+ person_ids=[PersonId(pid) for pid in person_ids] if person_ids else None,
798
+ content=content,
799
+ date=date_value,
800
+ direction=parsed_direction,
801
+ ),
802
+ )
803
+
804
+ # Build CommandContext for interaction update
805
+ ctx_modifiers: dict[str, object] = {"type": interaction_type}
806
+ if person_ids:
807
+ ctx_modifiers["personIds"] = list(person_ids)
808
+ if content:
809
+ ctx_modifiers["content"] = content
810
+ if date:
811
+ ctx_modifiers["date"] = date
812
+ if direction:
813
+ ctx_modifiers["direction"] = direction
814
+
815
+ cmd_context = CommandContext(
816
+ name="interaction update",
817
+ inputs={"interactionId": interaction_id},
818
+ modifiers=ctx_modifiers,
819
+ )
820
+
821
+ return CommandOutput(
822
+ data={"interaction": _interaction_payload(interaction)},
823
+ context=cmd_context,
824
+ api_called=True,
825
+ )
826
+
827
+ run_command(ctx, command="interaction update", fn=fn)
828
+
829
+
830
+ @category("write")
831
+ @destructive
832
+ @interaction_group.command(name="delete", cls=RichCommand)
833
+ @click.argument("interaction_id", type=int)
834
+ @click.option(
835
+ "--type",
836
+ "-t",
837
+ "interaction_type",
838
+ type=click.Choice(sorted(_INTERACTION_TYPE_MAP.keys())),
839
+ required=True,
840
+ help="Interaction type (required by API).",
841
+ )
842
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
843
+ @output_options
844
+ @click.pass_obj
845
+ def interaction_delete(
846
+ ctx: CLIContext, interaction_id: int, *, interaction_type: str, yes: bool
847
+ ) -> None:
848
+ """Delete an interaction."""
849
+ if not yes:
850
+ click.confirm(f"Delete interaction {interaction_id}?", abort=True)
851
+
852
+ def fn(ctx: CLIContext, warnings: list[str]) -> CommandOutput:
853
+ parsed_type = parse_choice(
854
+ interaction_type,
855
+ _INTERACTION_TYPE_MAP,
856
+ label="interaction type",
857
+ )
858
+ if parsed_type is None:
859
+ raise CLIError("Missing interaction type.", error_type="usage_error", exit_code=2)
860
+ client = ctx.get_client(warnings=warnings)
861
+ success = client.interactions.delete(InteractionId(interaction_id), parsed_type)
862
+
863
+ cmd_context = CommandContext(
864
+ name="interaction delete",
865
+ inputs={"interactionId": interaction_id},
866
+ modifiers={"type": interaction_type},
867
+ )
868
+
869
+ return CommandOutput(
870
+ data={"success": success},
871
+ context=cmd_context,
872
+ api_called=True,
873
+ )
874
+
875
+ run_command(ctx, command="interaction delete", fn=fn)