abstractcore 2.9.1__py3-none-any.whl → 2.11.2__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 (83) hide show
  1. abstractcore/__init__.py +7 -27
  2. abstractcore/apps/extractor.py +33 -100
  3. abstractcore/apps/intent.py +19 -0
  4. abstractcore/apps/judge.py +20 -1
  5. abstractcore/apps/summarizer.py +20 -1
  6. abstractcore/architectures/detection.py +34 -1
  7. abstractcore/architectures/response_postprocessing.py +313 -0
  8. abstractcore/assets/architecture_formats.json +38 -8
  9. abstractcore/assets/model_capabilities.json +781 -160
  10. abstractcore/compression/__init__.py +1 -2
  11. abstractcore/compression/glyph_processor.py +6 -4
  12. abstractcore/config/main.py +31 -19
  13. abstractcore/config/manager.py +389 -11
  14. abstractcore/config/vision_config.py +5 -5
  15. abstractcore/core/interface.py +151 -3
  16. abstractcore/core/session.py +16 -10
  17. abstractcore/download.py +1 -1
  18. abstractcore/embeddings/manager.py +20 -6
  19. abstractcore/endpoint/__init__.py +2 -0
  20. abstractcore/endpoint/app.py +458 -0
  21. abstractcore/mcp/client.py +3 -1
  22. abstractcore/media/__init__.py +52 -17
  23. abstractcore/media/auto_handler.py +42 -22
  24. abstractcore/media/base.py +44 -1
  25. abstractcore/media/capabilities.py +12 -33
  26. abstractcore/media/enrichment.py +105 -0
  27. abstractcore/media/handlers/anthropic_handler.py +19 -28
  28. abstractcore/media/handlers/local_handler.py +124 -70
  29. abstractcore/media/handlers/openai_handler.py +19 -31
  30. abstractcore/media/processors/__init__.py +4 -2
  31. abstractcore/media/processors/audio_processor.py +57 -0
  32. abstractcore/media/processors/office_processor.py +8 -3
  33. abstractcore/media/processors/pdf_processor.py +46 -3
  34. abstractcore/media/processors/text_processor.py +22 -24
  35. abstractcore/media/processors/video_processor.py +58 -0
  36. abstractcore/media/types.py +97 -4
  37. abstractcore/media/utils/image_scaler.py +20 -2
  38. abstractcore/media/utils/video_frames.py +219 -0
  39. abstractcore/media/vision_fallback.py +136 -22
  40. abstractcore/processing/__init__.py +32 -3
  41. abstractcore/processing/basic_deepsearch.py +15 -10
  42. abstractcore/processing/basic_intent.py +3 -2
  43. abstractcore/processing/basic_judge.py +3 -2
  44. abstractcore/processing/basic_summarizer.py +1 -1
  45. abstractcore/providers/__init__.py +3 -1
  46. abstractcore/providers/anthropic_provider.py +95 -8
  47. abstractcore/providers/base.py +1516 -81
  48. abstractcore/providers/huggingface_provider.py +546 -69
  49. abstractcore/providers/lmstudio_provider.py +35 -923
  50. abstractcore/providers/mlx_provider.py +382 -35
  51. abstractcore/providers/model_capabilities.py +5 -1
  52. abstractcore/providers/ollama_provider.py +99 -15
  53. abstractcore/providers/openai_compatible_provider.py +406 -180
  54. abstractcore/providers/openai_provider.py +188 -44
  55. abstractcore/providers/openrouter_provider.py +76 -0
  56. abstractcore/providers/registry.py +61 -5
  57. abstractcore/providers/streaming.py +138 -33
  58. abstractcore/providers/vllm_provider.py +92 -817
  59. abstractcore/server/app.py +461 -13
  60. abstractcore/server/audio_endpoints.py +139 -0
  61. abstractcore/server/vision_endpoints.py +1319 -0
  62. abstractcore/structured/handler.py +316 -41
  63. abstractcore/tools/common_tools.py +5501 -2012
  64. abstractcore/tools/comms_tools.py +1641 -0
  65. abstractcore/tools/core.py +37 -7
  66. abstractcore/tools/handler.py +4 -9
  67. abstractcore/tools/parser.py +49 -2
  68. abstractcore/tools/tag_rewriter.py +2 -1
  69. abstractcore/tools/telegram_tdlib.py +407 -0
  70. abstractcore/tools/telegram_tools.py +261 -0
  71. abstractcore/utils/cli.py +1085 -72
  72. abstractcore/utils/token_utils.py +2 -0
  73. abstractcore/utils/truncation.py +29 -0
  74. abstractcore/utils/version.py +3 -4
  75. abstractcore/utils/vlm_token_calculator.py +12 -2
  76. abstractcore-2.11.2.dist-info/METADATA +562 -0
  77. abstractcore-2.11.2.dist-info/RECORD +133 -0
  78. {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/WHEEL +1 -1
  79. {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/entry_points.txt +1 -0
  80. abstractcore-2.9.1.dist-info/METADATA +0 -1190
  81. abstractcore-2.9.1.dist-info/RECORD +0 -119
  82. {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/licenses/LICENSE +0 -0
  83. {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/top_level.txt +0 -0
@@ -2,7 +2,7 @@
2
2
  Core tool definitions and abstractions.
3
3
  """
4
4
 
5
- from typing import Dict, Any, List, Optional, Callable
5
+ from typing import Dict, Any, List, Optional, Callable, Union, get_args, get_origin
6
6
  from dataclasses import dataclass, field
7
7
  from abc import ABC, abstractmethod
8
8
 
@@ -75,6 +75,7 @@ class ToolDefinition:
75
75
  def from_function(cls, func: Callable) -> 'ToolDefinition':
76
76
  """Create tool definition from a function"""
77
77
  import inspect
78
+ import types
78
79
 
79
80
  # Extract function name and docstring
80
81
  name = func.__name__
@@ -88,17 +89,44 @@ class ToolDefinition:
88
89
  sig = inspect.signature(func)
89
90
  parameters = {}
90
91
 
92
+ def _schema_for_annotation(annotation: Any) -> Dict[str, Any]:
93
+ if annotation in {inspect._empty, None}:
94
+ return {"type": "string"}
95
+
96
+ if annotation is str:
97
+ return {"type": "string"}
98
+ if annotation is int:
99
+ return {"type": "integer"}
100
+ if annotation is float:
101
+ return {"type": "number"}
102
+ if annotation is bool:
103
+ return {"type": "boolean"}
104
+
105
+ origin = get_origin(annotation)
106
+ args = get_args(annotation)
107
+
108
+ # Optional[T] (Union[T, None]) => schema(T)
109
+ if origin in {Union, getattr(types, "UnionType", object())}:
110
+ non_none = [a for a in args if a is not type(None)]
111
+ if len(non_none) == 1:
112
+ return _schema_for_annotation(non_none[0])
113
+
114
+ if origin in {list, List, tuple, set}:
115
+ item_schema = _schema_for_annotation(args[0]) if args else {"type": "string"}
116
+ return {"type": "array", "items": item_schema}
117
+
118
+ if origin in {dict, Dict}:
119
+ return {"type": "object"}
120
+
121
+ # Fall back to string for unknown / complex annotations.
122
+ return {"type": "string"}
123
+
91
124
  for param_name, param in sig.parameters.items():
92
125
  param_info = {"type": "string"} # Default type
93
126
 
94
127
  # Try to infer type from annotation
95
128
  if param.annotation != param.empty:
96
- if param.annotation == int:
97
- param_info["type"] = "integer"
98
- elif param.annotation == float:
99
- param_info["type"] = "number"
100
- elif param.annotation == bool:
101
- param_info["type"] = "boolean"
129
+ param_info = _schema_for_annotation(param.annotation)
102
130
 
103
131
  if param.default != param.empty:
104
132
  param_info["default"] = param.default
@@ -254,6 +282,8 @@ def tool(
254
282
 
255
283
  # Attach tool definition to function for easy access
256
284
  f._tool_definition = tool_def
285
+ # Public alias (docs/examples use this name).
286
+ f.tool_definition = tool_def
257
287
  f.tool_name = tool_name
258
288
 
259
289
  return f
@@ -134,15 +134,10 @@ class UniversalToolHandler:
134
134
  }
135
135
  }
136
136
  }
137
-
138
- # Add enhanced metadata if available (provider-agnostic)
139
- if tool_def.tags:
140
- native_tool["function"]["tags"] = tool_def.tags
141
- if tool_def.when_to_use:
142
- native_tool["function"]["when_to_use"] = tool_def.when_to_use
143
- if tool_def.examples:
144
- native_tool["function"]["examples"] = tool_def.examples
145
-
137
+
138
+ # NOTE: Do not include custom keys (tags/when_to_use/examples) in native tool payloads.
139
+ # Most provider native tool schemas validate strictly and may reject unknown fields.
140
+
146
141
  native_tools.append(native_tool)
147
142
 
148
143
  return native_tools
@@ -274,7 +274,7 @@ def _get_tool_format(model_name: Optional[str]) -> ToolFormat:
274
274
  # `_parse_any_format` when the model emits a different convention.
275
275
  if tool_format == "special_token":
276
276
  return ToolFormat.SPECIAL_TOKEN
277
- if tool_format == "xml":
277
+ if tool_format in {"xml", "glm_xml"}:
278
278
  return ToolFormat.XML_WRAPPED
279
279
  if tool_format == "pythonic":
280
280
  return ToolFormat.TOOL_CODE
@@ -1024,12 +1024,39 @@ def _format_parameters_compact(parameters: Dict[str, Any]) -> str:
1024
1024
  return ", ".join(parts) if parts else "(none)"
1025
1025
 
1026
1026
 
1027
+ # Prompt footprint control: include `when_to_use` only when it will meaningfully
1028
+ # improve tool selection, without bloating large tool catalogs.
1029
+ _WHEN_TO_USE_PRIORITY_TOOLS: set[str] = {
1030
+ # Core editing loop / safety-sensitive tools
1031
+ "edit_file",
1032
+ "write_file",
1033
+ "execute_command",
1034
+ # Common web triage workflow tools
1035
+ "web_search",
1036
+ "skim_websearch",
1037
+ "skim_url",
1038
+ "fetch_url",
1039
+ }
1040
+
1041
+
1042
+ def _should_render_when_to_use(tool: ToolDefinition, *, total_tools: int) -> bool:
1043
+ when = getattr(tool, "when_to_use", None)
1044
+ if not when:
1045
+ return False
1046
+ # If the tool list is small, include all `when_to_use` guidance.
1047
+ if total_tools <= 6:
1048
+ return True
1049
+ # For larger tool catalogs, include guidance only for priority tools.
1050
+ name = str(getattr(tool, "name", "") or "").strip()
1051
+ return name in _WHEN_TO_USE_PRIORITY_TOOLS
1052
+
1053
+
1027
1054
  def _append_tool_examples(
1028
1055
  prompt: str,
1029
1056
  tools: List[ToolDefinition],
1030
1057
  *,
1031
1058
  tool_format: ToolFormat,
1032
- max_examples_total: int = 6,
1059
+ max_examples_total: int = 8,
1033
1060
  ) -> str:
1034
1061
  """Append a small, globally-capped examples section.
1035
1062
 
@@ -1052,6 +1079,8 @@ def _append_tool_examples(
1052
1079
  "edit_file",
1053
1080
  "write_file",
1054
1081
  "execute_command",
1082
+ "skim_websearch",
1083
+ "skim_url",
1055
1084
  "fetch_url",
1056
1085
  "web_search",
1057
1086
  ]
@@ -1097,10 +1126,13 @@ def _format_qwen_style(tools: List[ToolDefinition], *, include_tool_list: bool =
1097
1126
  prompt = "You are a helpful AI assistant with access to the following tools:\n\n"
1098
1127
 
1099
1128
  if include_tool_list:
1129
+ total_tools = len(tools)
1100
1130
  for tool in tools:
1101
1131
  prompt += f"**{tool.name}**: {tool.description}\n"
1102
1132
  if tool.parameters:
1103
1133
  prompt += f" • **Args**: {_format_parameters_compact(tool.parameters)}\n"
1134
+ if _should_render_when_to_use(tool, total_tools=total_tools):
1135
+ prompt += f" • **When**: {tool.when_to_use}\n"
1104
1136
  prompt += "\n"
1105
1137
 
1106
1138
  prompt += """To use a tool, respond with one or more tool-call blocks (no other text):
@@ -1126,10 +1158,13 @@ def _format_llama_style(tools: List[ToolDefinition], *, include_tool_list: bool
1126
1158
  prompt = "You have access to the following functions. Use them when needed:\n\n"
1127
1159
 
1128
1160
  if include_tool_list:
1161
+ total_tools = len(tools)
1129
1162
  for tool in tools:
1130
1163
  prompt += f"**{tool.name}**: {tool.description}\n"
1131
1164
  if tool.parameters:
1132
1165
  prompt += f" • **Args**: {_format_parameters_compact(tool.parameters)}\n"
1166
+ if _should_render_when_to_use(tool, total_tools=total_tools):
1167
+ prompt += f" • **When**: {tool.when_to_use}\n"
1133
1168
  prompt += "\n"
1134
1169
 
1135
1170
  prompt += """To call a function, output one or more <function_call> blocks (no other text):
@@ -1154,11 +1189,14 @@ def _format_xml_style(tools: List[ToolDefinition], *, include_tool_list: bool =
1154
1189
  prompt = "You have access to these tools:\n\n"
1155
1190
 
1156
1191
  if include_tool_list:
1192
+ total_tools = len(tools)
1157
1193
  for tool in tools:
1158
1194
  prompt += f'<tool name="{tool.name}">\n'
1159
1195
  prompt += f" <description>{tool.description}</description>\n"
1160
1196
  if tool.parameters:
1161
1197
  prompt += f" <args>{_format_parameters_compact(tool.parameters)}</args>\n"
1198
+ if _should_render_when_to_use(tool, total_tools=total_tools):
1199
+ prompt += f" <when_to_use>{tool.when_to_use}</when_to_use>\n"
1162
1200
  prompt += "</tool>\n\n"
1163
1201
 
1164
1202
  prompt += """To use a tool, output one or more <tool_call> blocks (no other text):
@@ -1183,10 +1221,13 @@ def _format_json_style(tools: List[ToolDefinition], *, include_tool_list: bool =
1183
1221
  prompt = "You have access to the following tools:\n\n"
1184
1222
 
1185
1223
  if include_tool_list:
1224
+ total_tools = len(tools)
1186
1225
  for tool in tools:
1187
1226
  prompt += f"- {tool.name}: {tool.description}\n"
1188
1227
  if tool.parameters:
1189
1228
  prompt += f" args: {_format_parameters_compact(tool.parameters)}\n"
1229
+ if _should_render_when_to_use(tool, total_tools=total_tools):
1230
+ prompt += f" when: {tool.when_to_use}\n"
1190
1231
 
1191
1232
  prompt += """To use a tool, respond with one or more JSON objects (no extra text):
1192
1233
  {"name": "tool_name", "arguments": {"param1": "value1", "param2": "value2"}}
@@ -1208,10 +1249,13 @@ def _format_gemma_style(tools: List[ToolDefinition], *, include_tool_list: bool
1208
1249
  prompt = "You can use these tools by writing tool_code blocks:\n\n"
1209
1250
 
1210
1251
  if include_tool_list:
1252
+ total_tools = len(tools)
1211
1253
  for tool in tools:
1212
1254
  prompt += f"**{tool.name}**: {tool.description}\n"
1213
1255
  if tool.parameters:
1214
1256
  prompt += f"Args: {_format_parameters_compact(tool.parameters)}\n"
1257
+ if _should_render_when_to_use(tool, total_tools=total_tools):
1258
+ prompt += f"When: {tool.when_to_use}\n"
1215
1259
  prompt += "\n"
1216
1260
 
1217
1261
  prompt += """To call a tool, output one or more tool_code blocks (no other text):
@@ -1235,10 +1279,13 @@ def _format_generic_style(tools: List[ToolDefinition], *, include_tool_list: boo
1235
1279
  prompt = "You have access to the following tools:\n\n"
1236
1280
 
1237
1281
  if include_tool_list:
1282
+ total_tools = len(tools)
1238
1283
  for tool in tools:
1239
1284
  prompt += f"- {tool.name}: {tool.description}\n"
1240
1285
  if tool.parameters:
1241
1286
  prompt += f" args: {_format_parameters_compact(tool.parameters)}\n"
1287
+ if _should_render_when_to_use(tool, total_tools=total_tools):
1288
+ prompt += f" when: {tool.when_to_use}\n"
1242
1289
 
1243
1290
  prompt += _critical_rules()
1244
1291
 
@@ -11,6 +11,7 @@ from typing import Dict, Any, Optional, Tuple, List
11
11
  from dataclasses import dataclass
12
12
  from ..utils.structured_logging import get_logger
13
13
  from ..utils.jsonish import loads_dict_like as _loads_dict_like
14
+ from ..utils.truncation import preview_text
14
15
 
15
16
  logger = get_logger(__name__)
16
17
 
@@ -165,7 +166,7 @@ class ToolCallTagRewriter:
165
166
  Returns:
166
167
  Text with rewritten tool call tags
167
168
  """
168
- logger.debug(f"rewrite_text called with text: {text[:100] if text else None}")
169
+ logger.debug(f"rewrite_text called with text: {preview_text(text, max_chars=100) if text else None}")
169
170
  logger.debug(f"Target output tags: start='{self._output_start_tag}', end='{self._output_end_tag}'")
170
171
 
171
172
  if not text or not self.target_tags.preserve_json:
@@ -0,0 +1,407 @@
1
+ """TDLib (tdjson) wrapper used for Telegram Secret Chats (E2EE).
2
+
3
+ Why this exists:
4
+ - Telegram Bot API does *not* support Secret Chats (end-to-end encryption).
5
+ - TDLib is Telegram's official client library that supports Secret Chats and handles
6
+ encryption, re-keying, sequencing, and media download/upload.
7
+
8
+ Design constraints (framework-wide):
9
+ - Keep Python dependencies minimal (stdlib-only here; TDLib itself is an external binary).
10
+ - Keep the interface JSON-safe for durable tool execution.
11
+ - Avoid side effects at import time: the tdjson shared library is loaded lazily.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ import ctypes
18
+ import json
19
+ import os
20
+ import threading
21
+ import time
22
+ import uuid
23
+ from typing import Any, Callable, Dict, Optional
24
+
25
+
26
+ class TdlibNotAvailable(RuntimeError):
27
+ pass
28
+
29
+
30
+ def _as_bool(raw: Any, default: bool) -> bool:
31
+ if raw is None:
32
+ return default
33
+ if isinstance(raw, bool):
34
+ return raw
35
+ s = str(raw).strip().lower()
36
+ if not s:
37
+ return default
38
+ if s in {"1", "true", "yes", "y", "on"}:
39
+ return True
40
+ if s in {"0", "false", "no", "n", "off"}:
41
+ return False
42
+ return default
43
+
44
+
45
+ def _load_tdjson() -> ctypes.CDLL:
46
+ """Load TDLib tdjson shared library.
47
+
48
+ Users can override the location via:
49
+ - ABSTRACT_TELEGRAM_TDJSON_PATH=/absolute/path/to/libtdjson.{so|dylib|dll}
50
+ """
51
+
52
+ override = str(os.getenv("ABSTRACT_TELEGRAM_TDJSON_PATH", "") or "").strip()
53
+ candidates: list[str] = []
54
+ if override:
55
+ candidates.append(override)
56
+
57
+ # Common names (platform dependent). We try them all and let ctypes resolve.
58
+ candidates.extend(
59
+ [
60
+ "libtdjson.dylib",
61
+ "libtdjson.so",
62
+ "tdjson.dll",
63
+ "libtdjson.dll",
64
+ ]
65
+ )
66
+
67
+ last_err: Optional[Exception] = None
68
+ for name in candidates:
69
+ try:
70
+ return ctypes.CDLL(name)
71
+ except Exception as e:
72
+ last_err = e
73
+
74
+ hint = (
75
+ "TDLib (tdjson) is required for Telegram Secret Chats. "
76
+ "Install/build TDLib and set ABSTRACT_TELEGRAM_TDJSON_PATH to the tdjson shared library."
77
+ )
78
+ raise TdlibNotAvailable(f"Failed loading tdjson library. {hint} (last error: {last_err})")
79
+
80
+
81
+ class _TdJsonBindings:
82
+ def __init__(self, lib: ctypes.CDLL):
83
+ self._lib = lib
84
+
85
+ self.td_json_client_create = lib.td_json_client_create
86
+ self.td_json_client_create.restype = ctypes.c_void_p
87
+
88
+ self.td_json_client_destroy = lib.td_json_client_destroy
89
+ self.td_json_client_destroy.argtypes = [ctypes.c_void_p]
90
+ self.td_json_client_destroy.restype = None
91
+
92
+ self.td_json_client_send = lib.td_json_client_send
93
+ self.td_json_client_send.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
94
+ self.td_json_client_send.restype = None
95
+
96
+ self.td_json_client_receive = lib.td_json_client_receive
97
+ self.td_json_client_receive.argtypes = [ctypes.c_void_p, ctypes.c_double]
98
+ self.td_json_client_receive.restype = ctypes.c_char_p
99
+
100
+ # NOTE: td_json_client_execute is available but only supports synchronous methods.
101
+ # We keep it for completeness; main flow uses send/receive.
102
+ self.td_json_client_execute = getattr(lib, "td_json_client_execute", None)
103
+ if self.td_json_client_execute is not None:
104
+ self.td_json_client_execute.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
105
+ self.td_json_client_execute.restype = ctypes.c_char_p
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class TdlibConfig:
110
+ api_id: int
111
+ api_hash: str
112
+ phone: str
113
+ database_directory: str
114
+ files_directory: str
115
+ database_encryption_key: str = ""
116
+ use_secret_chats: bool = True
117
+ # Optional: provide non-interactive auth for initial bootstrap (not recommended for production).
118
+ login_code: Optional[str] = None
119
+ two_factor_password: Optional[str] = None
120
+
121
+ @staticmethod
122
+ def from_env() -> "TdlibConfig":
123
+ def _require(name: str) -> str:
124
+ v = str(os.getenv(name, "") or "").strip()
125
+ if not v:
126
+ raise ValueError(f"Missing env var {name}")
127
+ return v
128
+
129
+ api_id_raw = _require("ABSTRACT_TELEGRAM_API_ID")
130
+ try:
131
+ api_id = int(api_id_raw)
132
+ except Exception as e:
133
+ raise ValueError("ABSTRACT_TELEGRAM_API_ID must be an integer") from e
134
+
135
+ api_hash = _require("ABSTRACT_TELEGRAM_API_HASH")
136
+ phone = _require("ABSTRACT_TELEGRAM_PHONE_NUMBER")
137
+
138
+ db_dir = _require("ABSTRACT_TELEGRAM_DB_DIR")
139
+ files_dir = str(os.getenv("ABSTRACT_TELEGRAM_FILES_DIR", "") or "").strip() or db_dir
140
+
141
+ db_key = str(os.getenv("ABSTRACT_TELEGRAM_DB_ENCRYPTION_KEY", "") or "")
142
+ use_secret = _as_bool(os.getenv("ABSTRACT_TELEGRAM_USE_SECRET_CHATS"), True)
143
+
144
+ login_code = str(os.getenv("ABSTRACT_TELEGRAM_LOGIN_CODE", "") or "").strip() or None
145
+ two_factor = str(os.getenv("ABSTRACT_TELEGRAM_2FA_PASSWORD", "") or "").strip() or None
146
+
147
+ return TdlibConfig(
148
+ api_id=api_id,
149
+ api_hash=api_hash,
150
+ phone=phone,
151
+ database_directory=db_dir,
152
+ files_directory=files_dir,
153
+ database_encryption_key=db_key,
154
+ use_secret_chats=bool(use_secret),
155
+ login_code=login_code,
156
+ two_factor_password=two_factor,
157
+ )
158
+
159
+
160
+ class TdlibClient:
161
+ """A single-process TDLib client with a background receive loop.
162
+
163
+ TDLib requires that only one client instance accesses the database directory.
164
+ This class is therefore designed to be used as a process-wide singleton.
165
+ """
166
+
167
+ def __init__(self, *, config: TdlibConfig):
168
+ self._config = config
169
+ self._td = _TdJsonBindings(_load_tdjson())
170
+ self._client = self._td.td_json_client_create()
171
+ if not self._client:
172
+ raise TdlibNotAvailable("td_json_client_create() returned NULL")
173
+
174
+ self._running = False
175
+ self._thread: Optional[threading.Thread] = None
176
+
177
+ self._ready = threading.Event()
178
+ self._closed = threading.Event()
179
+ self._last_error: Optional[str] = None
180
+
181
+ self._pending_lock = threading.Lock()
182
+ self._pending: Dict[str, Dict[str, Any]] = {}
183
+
184
+ self._handlers_lock = threading.Lock()
185
+ self._update_handlers: list[Callable[[Dict[str, Any]], None]] = []
186
+
187
+ @property
188
+ def last_error(self) -> Optional[str]:
189
+ return self._last_error
190
+
191
+ def add_update_handler(self, handler: Callable[[Dict[str, Any]], None]) -> None:
192
+ with self._handlers_lock:
193
+ self._update_handlers.append(handler)
194
+
195
+ def start(self) -> None:
196
+ if self._running:
197
+ return
198
+ self._running = True
199
+ self._thread = threading.Thread(target=self._loop, name="tdlib-recv", daemon=True)
200
+ self._thread.start()
201
+
202
+ # Kick TDLib to emit initial auth state.
203
+ self.send({"@type": "getAuthorizationState"})
204
+
205
+ def stop(self) -> None:
206
+ self._running = False
207
+ try:
208
+ self.send({"@type": "close"})
209
+ except Exception:
210
+ pass
211
+ if self._thread is not None:
212
+ try:
213
+ self._thread.join(timeout=3.0)
214
+ except Exception:
215
+ pass
216
+ try:
217
+ self._td.td_json_client_destroy(self._client)
218
+ except Exception:
219
+ pass
220
+
221
+ def wait_until_ready(self, *, timeout_s: float = 30.0) -> bool:
222
+ return self._ready.wait(timeout=max(0.0, float(timeout_s)))
223
+
224
+ def send(self, req: Dict[str, Any]) -> None:
225
+ data = json.dumps(req, ensure_ascii=False).encode("utf-8")
226
+ self._td.td_json_client_send(self._client, ctypes.c_char_p(data))
227
+
228
+ def request(self, req: Dict[str, Any], *, timeout_s: float = 15.0) -> Dict[str, Any]:
229
+ extra = uuid.uuid4().hex
230
+ req2 = dict(req)
231
+ req2["@extra"] = extra
232
+ done = threading.Event()
233
+ slot: Dict[str, Any] = {"done": done, "response": None}
234
+ with self._pending_lock:
235
+ self._pending[extra] = slot
236
+ self.send(req2)
237
+ ok = done.wait(timeout=max(0.0, float(timeout_s)))
238
+ with self._pending_lock:
239
+ self._pending.pop(extra, None)
240
+ if not ok:
241
+ raise TimeoutError(f"TDLib request timed out after {timeout_s}s (@type={req.get('@type')})")
242
+ resp = slot.get("response")
243
+ return dict(resp) if isinstance(resp, dict) else {"@type": "error", "message": "Invalid response"}
244
+
245
+ # ---------------------------------------------------------------------
246
+ # Internal loop + authorization
247
+ # ---------------------------------------------------------------------
248
+
249
+ def _loop(self) -> None:
250
+ # Best-effort: reduce noise if supported.
251
+ try:
252
+ self.send({"@type": "setLogVerbosityLevel", "new_verbosity_level": 1})
253
+ except Exception:
254
+ pass
255
+
256
+ while self._running and not self._closed.is_set():
257
+ try:
258
+ raw = self._td.td_json_client_receive(self._client, ctypes.c_double(1.0))
259
+ except Exception as e:
260
+ self._last_error = str(e)
261
+ time.sleep(0.25)
262
+ continue
263
+
264
+ if not raw:
265
+ continue
266
+ try:
267
+ text = raw.decode("utf-8", errors="replace")
268
+ msg = json.loads(text)
269
+ except Exception:
270
+ continue
271
+ if not isinstance(msg, dict):
272
+ continue
273
+
274
+ # Route explicit responses to pending waiters.
275
+ extra = msg.get("@extra")
276
+ if isinstance(extra, str) and extra:
277
+ with self._pending_lock:
278
+ slot = self._pending.get(extra)
279
+ if slot is not None:
280
+ slot["response"] = msg
281
+ done = slot.get("done")
282
+ if isinstance(done, threading.Event):
283
+ done.set()
284
+ continue
285
+
286
+ typ = msg.get("@type")
287
+ if typ == "updateAuthorizationState":
288
+ try:
289
+ self._handle_auth_update(msg)
290
+ except Exception as e:
291
+ self._last_error = str(e)
292
+ continue
293
+
294
+ if typ == "updateConnectionState":
295
+ # Useful for debugging; no-op.
296
+ continue
297
+
298
+ # Broadcast other updates.
299
+ if isinstance(typ, str) and typ.startswith("update"):
300
+ with self._handlers_lock:
301
+ handlers = list(self._update_handlers)
302
+ for h in handlers:
303
+ try:
304
+ h(msg)
305
+ except Exception:
306
+ continue
307
+
308
+ def _handle_auth_update(self, update: Dict[str, Any]) -> None:
309
+ state = update.get("authorization_state")
310
+ if not isinstance(state, dict):
311
+ return
312
+ st = str(state.get("@type") or "").strip()
313
+ if not st:
314
+ return
315
+
316
+ if st == "authorizationStateReady":
317
+ self._ready.set()
318
+ return
319
+
320
+ if st == "authorizationStateClosed":
321
+ self._closed.set()
322
+ return
323
+
324
+ if st == "authorizationStateWaitTdlibParameters":
325
+ cfg = self._config
326
+ params = {
327
+ "@type": "setTdlibParameters",
328
+ "parameters": {
329
+ "database_directory": cfg.database_directory,
330
+ "files_directory": cfg.files_directory,
331
+ "use_message_database": True,
332
+ "use_secret_chats": bool(cfg.use_secret_chats),
333
+ "use_file_database": True,
334
+ "use_chat_info_database": True,
335
+ "api_id": int(cfg.api_id),
336
+ "api_hash": str(cfg.api_hash),
337
+ "system_language_code": "en",
338
+ "device_model": "AbstractFramework",
339
+ "system_version": "0.0",
340
+ "application_version": "0.0",
341
+ "enable_storage_optimizer": True,
342
+ },
343
+ }
344
+ self.send(params)
345
+ return
346
+
347
+ if st == "authorizationStateWaitEncryptionKey":
348
+ # If the database is encrypted, we must provide the key.
349
+ self.send(
350
+ {
351
+ "@type": "checkDatabaseEncryptionKey",
352
+ "encryption_key": self._config.database_encryption_key or "",
353
+ }
354
+ )
355
+ return
356
+
357
+ if st == "authorizationStateWaitPhoneNumber":
358
+ self.send({"@type": "setAuthenticationPhoneNumber", "phone_number": self._config.phone})
359
+ return
360
+
361
+ if st == "authorizationStateWaitCode":
362
+ code = self._config.login_code
363
+ if not code:
364
+ self._last_error = (
365
+ "TDLib needs a login code (authorizationStateWaitCode) but ABSTRACT_TELEGRAM_LOGIN_CODE is not set. "
366
+ "Run the one-time bootstrap flow described in docs/guide/telegram-integration.md to create the session."
367
+ )
368
+ return
369
+ self.send({"@type": "checkAuthenticationCode", "code": code})
370
+ return
371
+
372
+ if st == "authorizationStateWaitPassword":
373
+ pw = self._config.two_factor_password
374
+ if not pw:
375
+ self._last_error = (
376
+ "TDLib requires a 2FA password (authorizationStateWaitPassword) but ABSTRACT_TELEGRAM_2FA_PASSWORD is not set."
377
+ )
378
+ return
379
+ self.send({"@type": "checkAuthenticationPassword", "password": pw})
380
+ return
381
+
382
+
383
+ _GLOBAL_TDLIB: Optional[TdlibClient] = None
384
+ _GLOBAL_TDLIB_LOCK = threading.Lock()
385
+
386
+
387
+ def get_global_tdlib_client(*, start: bool = True) -> TdlibClient:
388
+ global _GLOBAL_TDLIB
389
+ with _GLOBAL_TDLIB_LOCK:
390
+ if _GLOBAL_TDLIB is None:
391
+ cfg = TdlibConfig.from_env()
392
+ _GLOBAL_TDLIB = TdlibClient(config=cfg)
393
+ if start:
394
+ _GLOBAL_TDLIB.start()
395
+ return _GLOBAL_TDLIB
396
+
397
+
398
+ def stop_global_tdlib_client() -> None:
399
+ global _GLOBAL_TDLIB
400
+ with _GLOBAL_TDLIB_LOCK:
401
+ if _GLOBAL_TDLIB is None:
402
+ return
403
+ try:
404
+ _GLOBAL_TDLIB.stop()
405
+ finally:
406
+ _GLOBAL_TDLIB = None
407
+