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.
- abstractcore/__init__.py +7 -27
- abstractcore/apps/extractor.py +33 -100
- abstractcore/apps/intent.py +19 -0
- abstractcore/apps/judge.py +20 -1
- abstractcore/apps/summarizer.py +20 -1
- abstractcore/architectures/detection.py +34 -1
- abstractcore/architectures/response_postprocessing.py +313 -0
- abstractcore/assets/architecture_formats.json +38 -8
- abstractcore/assets/model_capabilities.json +781 -160
- abstractcore/compression/__init__.py +1 -2
- abstractcore/compression/glyph_processor.py +6 -4
- abstractcore/config/main.py +31 -19
- abstractcore/config/manager.py +389 -11
- abstractcore/config/vision_config.py +5 -5
- abstractcore/core/interface.py +151 -3
- abstractcore/core/session.py +16 -10
- abstractcore/download.py +1 -1
- abstractcore/embeddings/manager.py +20 -6
- abstractcore/endpoint/__init__.py +2 -0
- abstractcore/endpoint/app.py +458 -0
- abstractcore/mcp/client.py +3 -1
- abstractcore/media/__init__.py +52 -17
- abstractcore/media/auto_handler.py +42 -22
- abstractcore/media/base.py +44 -1
- abstractcore/media/capabilities.py +12 -33
- abstractcore/media/enrichment.py +105 -0
- abstractcore/media/handlers/anthropic_handler.py +19 -28
- abstractcore/media/handlers/local_handler.py +124 -70
- abstractcore/media/handlers/openai_handler.py +19 -31
- abstractcore/media/processors/__init__.py +4 -2
- abstractcore/media/processors/audio_processor.py +57 -0
- abstractcore/media/processors/office_processor.py +8 -3
- abstractcore/media/processors/pdf_processor.py +46 -3
- abstractcore/media/processors/text_processor.py +22 -24
- abstractcore/media/processors/video_processor.py +58 -0
- abstractcore/media/types.py +97 -4
- abstractcore/media/utils/image_scaler.py +20 -2
- abstractcore/media/utils/video_frames.py +219 -0
- abstractcore/media/vision_fallback.py +136 -22
- abstractcore/processing/__init__.py +32 -3
- abstractcore/processing/basic_deepsearch.py +15 -10
- abstractcore/processing/basic_intent.py +3 -2
- abstractcore/processing/basic_judge.py +3 -2
- abstractcore/processing/basic_summarizer.py +1 -1
- abstractcore/providers/__init__.py +3 -1
- abstractcore/providers/anthropic_provider.py +95 -8
- abstractcore/providers/base.py +1516 -81
- abstractcore/providers/huggingface_provider.py +546 -69
- abstractcore/providers/lmstudio_provider.py +35 -923
- abstractcore/providers/mlx_provider.py +382 -35
- abstractcore/providers/model_capabilities.py +5 -1
- abstractcore/providers/ollama_provider.py +99 -15
- abstractcore/providers/openai_compatible_provider.py +406 -180
- abstractcore/providers/openai_provider.py +188 -44
- abstractcore/providers/openrouter_provider.py +76 -0
- abstractcore/providers/registry.py +61 -5
- abstractcore/providers/streaming.py +138 -33
- abstractcore/providers/vllm_provider.py +92 -817
- abstractcore/server/app.py +461 -13
- abstractcore/server/audio_endpoints.py +139 -0
- abstractcore/server/vision_endpoints.py +1319 -0
- abstractcore/structured/handler.py +316 -41
- abstractcore/tools/common_tools.py +5501 -2012
- abstractcore/tools/comms_tools.py +1641 -0
- abstractcore/tools/core.py +37 -7
- abstractcore/tools/handler.py +4 -9
- abstractcore/tools/parser.py +49 -2
- abstractcore/tools/tag_rewriter.py +2 -1
- abstractcore/tools/telegram_tdlib.py +407 -0
- abstractcore/tools/telegram_tools.py +261 -0
- abstractcore/utils/cli.py +1085 -72
- abstractcore/utils/token_utils.py +2 -0
- abstractcore/utils/truncation.py +29 -0
- abstractcore/utils/version.py +3 -4
- abstractcore/utils/vlm_token_calculator.py +12 -2
- abstractcore-2.11.2.dist-info/METADATA +562 -0
- abstractcore-2.11.2.dist-info/RECORD +133 -0
- {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/WHEEL +1 -1
- {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/entry_points.txt +1 -0
- abstractcore-2.9.1.dist-info/METADATA +0 -1190
- abstractcore-2.9.1.dist-info/RECORD +0 -119
- {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/licenses/LICENSE +0 -0
- {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/top_level.txt +0 -0
abstractcore/tools/core.py
CHANGED
|
@@ -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
|
-
|
|
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
|
abstractcore/tools/handler.py
CHANGED
|
@@ -134,15 +134,10 @@ class UniversalToolHandler:
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
|
-
|
|
138
|
-
#
|
|
139
|
-
|
|
140
|
-
|
|
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
|
abstractcore/tools/parser.py
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
|
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
|
+
|