funstat-api 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Мейджи
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: funstat-api
3
+ Version: 0.1.0
4
+ Summary: Sync and async Python client for the Funstat API (Telegram user/group statistics)
5
+ Project-URL: Homepage, https://github.com/chizumeiji/funstat-api
6
+ Project-URL: Bug Tracker, https://github.com/chizumeiji/funstat-api/issues
7
+ Author-email: meiji <chizumeiji@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 Мейджи
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: api,client,funstat,statistics,telegram
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.10
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Programming Language :: Python :: 3.13
39
+ Classifier: Programming Language :: Python :: 3.14
40
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
41
+ Requires-Python: >=3.10
42
+ Requires-Dist: httpx>=0.24
43
+ Requires-Dist: pydantic>=2.0
44
+ Requires-Dist: requests>=2.28
45
+ Description-Content-Type: text/markdown
46
+
47
+ # funstat-api
48
+
49
+ Python client for the [Funstat](http://funstat.in/?start=0108FC1E9BEF75617466) API — Telegram user and group statistics.
50
+
51
+ Supports both **sync** and **async** usage.
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install funstat-api
57
+ ```
58
+
59
+ ## Quick Start
60
+
61
+ ### Sync
62
+
63
+ ```python
64
+ from funstat_api import FunstatClient
65
+
66
+ fs = FunstatClient("your_token")
67
+
68
+ # Get user stats
69
+ stats = fs.stats("durov")
70
+ print(stats.data.total_msg_count)
71
+
72
+ # Get group members
73
+ members = fs.get_group_members("https://t.me/mychat")
74
+
75
+ # Use as context manager
76
+ with FunstatClient("your_token") as fs:
77
+ print(fs.ping())
78
+ ```
79
+
80
+ ### Async
81
+
82
+ ```python
83
+ import asyncio
84
+ from funstat_api import AsyncFunstatClient
85
+
86
+ async def main():
87
+ async with AsyncFunstatClient("your_token") as fs:
88
+ stats = await fs.stats("durov")
89
+ print(stats.data.total_msg_count)
90
+
91
+ asyncio.run(main())
92
+ ```
93
+
94
+ ## Available Methods
95
+
96
+ | Method | Description |
97
+ |--------|-------------|
98
+ | `ping()` | Check API availability and latency |
99
+ | `get_balance()` | Get current token balance |
100
+ | `resolve_username(username)` | Resolve username to user info |
101
+ | `basic_info_by_id(ids)` | Get basic info by user ID(s) |
102
+ | `stats_min(user)` | Get minimal user statistics |
103
+ | `stats(user)` | Get full user statistics |
104
+ | `messages_count(user)` | Get total message count |
105
+ | `groups_count(user)` | Get number of groups |
106
+ | `get_messages(user, ...)` | Get paginated message list |
107
+ | `get_chats(user)` | Get user's chat list |
108
+ | `get_names(user)` | Get name history |
109
+ | `get_usernames(user)` | Get username history |
110
+ | `get_stickers(user)` | Get used sticker packs |
111
+ | `get_gifts(user)` | Get gift relations |
112
+ | `common_groups(user)` | Get common groups stats |
113
+ | `username_usage(username)` | Who uses or used a username |
114
+ | `get_group_info(group)` | Get group/channel info |
115
+ | `get_group_members(group)` | Get group members |
116
+ | `search_text(query)` | Search messages by text |
117
+
118
+ `user` and `group` arguments accept: numeric ID, `@username`, or `https://t.me/...` link.
119
+
120
+ ## Dependencies
121
+
122
+ - `pydantic >= 2.0`
123
+ - `requests >= 2.28` (sync client)
124
+ - `httpx >= 0.24` (async client)
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,82 @@
1
+ # funstat-api
2
+
3
+ Python client for the [Funstat](http://funstat.in/?start=0108FC1E9BEF75617466) API — Telegram user and group statistics.
4
+
5
+ Supports both **sync** and **async** usage.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install funstat-api
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ### Sync
16
+
17
+ ```python
18
+ from funstat_api import FunstatClient
19
+
20
+ fs = FunstatClient("your_token")
21
+
22
+ # Get user stats
23
+ stats = fs.stats("durov")
24
+ print(stats.data.total_msg_count)
25
+
26
+ # Get group members
27
+ members = fs.get_group_members("https://t.me/mychat")
28
+
29
+ # Use as context manager
30
+ with FunstatClient("your_token") as fs:
31
+ print(fs.ping())
32
+ ```
33
+
34
+ ### Async
35
+
36
+ ```python
37
+ import asyncio
38
+ from funstat_api import AsyncFunstatClient
39
+
40
+ async def main():
41
+ async with AsyncFunstatClient("your_token") as fs:
42
+ stats = await fs.stats("durov")
43
+ print(stats.data.total_msg_count)
44
+
45
+ asyncio.run(main())
46
+ ```
47
+
48
+ ## Available Methods
49
+
50
+ | Method | Description |
51
+ |--------|-------------|
52
+ | `ping()` | Check API availability and latency |
53
+ | `get_balance()` | Get current token balance |
54
+ | `resolve_username(username)` | Resolve username to user info |
55
+ | `basic_info_by_id(ids)` | Get basic info by user ID(s) |
56
+ | `stats_min(user)` | Get minimal user statistics |
57
+ | `stats(user)` | Get full user statistics |
58
+ | `messages_count(user)` | Get total message count |
59
+ | `groups_count(user)` | Get number of groups |
60
+ | `get_messages(user, ...)` | Get paginated message list |
61
+ | `get_chats(user)` | Get user's chat list |
62
+ | `get_names(user)` | Get name history |
63
+ | `get_usernames(user)` | Get username history |
64
+ | `get_stickers(user)` | Get used sticker packs |
65
+ | `get_gifts(user)` | Get gift relations |
66
+ | `common_groups(user)` | Get common groups stats |
67
+ | `username_usage(username)` | Who uses or used a username |
68
+ | `get_group_info(group)` | Get group/channel info |
69
+ | `get_group_members(group)` | Get group members |
70
+ | `search_text(query)` | Search messages by text |
71
+
72
+ `user` and `group` arguments accept: numeric ID, `@username`, or `https://t.me/...` link.
73
+
74
+ ## Dependencies
75
+
76
+ - `pydantic >= 2.0`
77
+ - `requests >= 2.28` (sync client)
78
+ - `httpx >= 0.24` (async client)
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,77 @@
1
+ from .client import (
2
+ FunstatClient,
3
+ AsyncFunstatClient,
4
+ FunstatError,
5
+ ResolveError,
6
+ ApiError,
7
+ # Models
8
+ TechInfo,
9
+ Paging,
10
+ ResolvedUser,
11
+ ChatInfo,
12
+ ChatInfoExt,
13
+ UserStatsMin,
14
+ UserStats,
15
+ UserMsg,
16
+ UserNameInfo,
17
+ UsrChatInfo,
18
+ GroupMember,
19
+ GiftRelationInfo,
20
+ StickerInfo,
21
+ UCommonGroupInfo,
22
+ UsernameUsageModel,
23
+ WhoWroteText,
24
+ PingResult,
25
+ # Response wrappers
26
+ ResolvedUserResponse,
27
+ UserStatsMinResponse,
28
+ UserStatsResponse,
29
+ UserMsgPagedResponse,
30
+ UserNameInfoResponse,
31
+ UsrChatInfoResponse,
32
+ GroupMemberResponse,
33
+ GiftRelationResponse,
34
+ StickerInfoResponse,
35
+ UCommonGroupInfoResponse,
36
+ UsernameUsageResponse,
37
+ ChatInfoExtResponse,
38
+ WhoWroteTextResponse,
39
+ )
40
+
41
+ __all__ = [
42
+ "FunstatClient",
43
+ "AsyncFunstatClient",
44
+ "FunstatError",
45
+ "ResolveError",
46
+ "ApiError",
47
+ "TechInfo",
48
+ "Paging",
49
+ "ResolvedUser",
50
+ "ChatInfo",
51
+ "ChatInfoExt",
52
+ "UserStatsMin",
53
+ "UserStats",
54
+ "UserMsg",
55
+ "UserNameInfo",
56
+ "UsrChatInfo",
57
+ "GroupMember",
58
+ "GiftRelationInfo",
59
+ "StickerInfo",
60
+ "UCommonGroupInfo",
61
+ "UsernameUsageModel",
62
+ "WhoWroteText",
63
+ "PingResult",
64
+ "ResolvedUserResponse",
65
+ "UserStatsMinResponse",
66
+ "UserStatsResponse",
67
+ "UserMsgPagedResponse",
68
+ "UserNameInfoResponse",
69
+ "UsrChatInfoResponse",
70
+ "GroupMemberResponse",
71
+ "GiftRelationResponse",
72
+ "StickerInfoResponse",
73
+ "UCommonGroupInfoResponse",
74
+ "UsernameUsageResponse",
75
+ "ChatInfoExtResponse",
76
+ "WhoWroteTextResponse",
77
+ ]
@@ -0,0 +1,609 @@
1
+ """
2
+ Funstat API client — sync and async modes.
3
+
4
+ Sync usage:
5
+ from funstat_api import FunstatClient
6
+ fs = FunstatClient("token")
7
+ print(fs.stats("durov"))
8
+
9
+ Async usage:
10
+ from funstat_api import AsyncFunstatClient
11
+ fs = AsyncFunstatClient("token")
12
+ print(await fs.stats("durov"))
13
+ """
14
+
15
+ from __future__ import annotations
16
+ import re
17
+ import time
18
+ from abc import ABC, abstractmethod
19
+ from typing import Any, Optional
20
+ import logging
21
+ from pydantic import BaseModel, Field
22
+
23
+ logger = logging.getLogger("funstat")
24
+
25
+ # ─────────────────────────────────────────────────────────────────────────────
26
+ # Exceptions
27
+ # ─────────────────────────────────────────────────────────────────────────────
28
+
29
+ class FunstatError(Exception):
30
+ """Base exception for all funstat API errors."""
31
+
32
+
33
+ class ResolveError(FunstatError):
34
+ """Raised when a username or group cannot be resolved to a numeric ID."""
35
+
36
+
37
+ class ApiError(FunstatError):
38
+ """Raised when the API returns a non-200 status code."""
39
+
40
+ def __init__(self, status_code: int, path: str) -> None:
41
+ self.status_code = status_code
42
+ self.path = path
43
+ super().__init__(f"HTTP {status_code} for {path}")
44
+
45
+ # ─────────────────────────────────────────────────────────────────────────────
46
+ # Models — shared
47
+ # ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ class TechInfo(BaseModel):
50
+ request_cost: float
51
+ current_ballance: float
52
+ request_duration: str
53
+
54
+ class Paging(BaseModel):
55
+ total: int
56
+ current_page: int = Field(alias="currentPage")
57
+ page_size: int = Field(alias="pageSize")
58
+ total_pages: int = Field(alias="totalPages")
59
+ model_config = {"populate_by_name": True}
60
+
61
+ # ─────────────────────────────────────────────────────────────────────────────
62
+ # Models — domain
63
+ # ─────────────────────────────────────────────────────────────────────────────
64
+
65
+ class ResolvedUser(BaseModel):
66
+ id: int
67
+ username: Optional[str] = None
68
+ first_name: Optional[str] = None
69
+ last_name: Optional[str] = None
70
+ is_active: bool
71
+ is_bot: bool
72
+ has_premium: Optional[bool] = None
73
+
74
+ class ChatInfo(BaseModel):
75
+ id: int
76
+ title: str
77
+ is_private: bool = Field(alias="isPrivate")
78
+ username: Optional[str] = None
79
+ model_config = {"populate_by_name": True}
80
+
81
+ class ChatInfoExt(BaseModel):
82
+ id: int
83
+ title: str
84
+ is_private: bool = Field(alias="isPrivate")
85
+ is_channel: bool = Field(alias="isChannel")
86
+ username: Optional[str] = None
87
+ link: Optional[str] = None
88
+ model_config = {"populate_by_name": True}
89
+
90
+ class UserStatsMin(BaseModel):
91
+ id: int
92
+ first_name: Optional[str] = None
93
+ last_name: Optional[str] = None
94
+ is_bot: bool
95
+ is_active: bool
96
+ first_msg_date: Optional[str] = None
97
+ last_msg_date: Optional[str] = None
98
+ total_msg_count: int
99
+ msg_in_groups_count: int
100
+ adm_in_groups: int
101
+ usernames_count: int
102
+ names_count: int
103
+ total_groups: int
104
+
105
+ class UserStats(UserStatsMin):
106
+ is_cyrillic_primary: Optional[bool] = None
107
+ lang_code: Optional[str] = None
108
+ unique_percent: Optional[float] = None
109
+ circle_count: int
110
+ voice_count: int
111
+ reply_percent: float
112
+ media_percent: float
113
+ link_percent: float
114
+ favorite_chat: Optional[ChatInfo] = None
115
+ media_usage: Optional[str] = None
116
+ stars_val: Optional[int] = None
117
+ personal_channel_id: Optional[int] = None
118
+ gift_count: Optional[int] = None
119
+ stars_level: Optional[int] = None
120
+ birth_day: Optional[int] = None
121
+ birth_month: Optional[int] = None
122
+ birth_year: Optional[int] = None
123
+ about: Optional[str] = None
124
+
125
+ class UserMsg(BaseModel):
126
+ date: str
127
+ message_id: int = Field(alias="messageId")
128
+ reply_to_message_id: Optional[int] = Field(default=None, alias="replyToMessageId")
129
+ media_code: Optional[int] = Field(default=None, alias="mediaCode")
130
+ media_name: Optional[str] = Field(default=None, alias="mediaName")
131
+ text: Optional[str] = None
132
+ group: ChatInfo
133
+ model_config = {"populate_by_name": True}
134
+
135
+ class UserNameInfo(BaseModel):
136
+ name: str
137
+ date_time: str
138
+
139
+ class UsrChatInfo(BaseModel):
140
+ chat: ChatInfo
141
+ last_message_id: int = Field(alias="lastMessageId")
142
+ messages_count: int = Field(alias="messagesCount")
143
+ last_message: Optional[str] = Field(default=None, alias="lastMessage")
144
+ first_message: Optional[str] = Field(default=None, alias="firstMessage")
145
+ is_admin: bool = Field(alias="isAdmin")
146
+ is_left: bool = Field(alias="isLeft")
147
+ model_config = {"populate_by_name": True}
148
+
149
+ class GroupMember(BaseModel):
150
+ id: int
151
+ username: Optional[str] = None
152
+ name: Optional[str] = None
153
+ first_name: Optional[str] = None
154
+ last_name: Optional[str] = None
155
+ is_admin: Optional[bool] = None
156
+ is_active: bool
157
+ today_msg: int
158
+ has_prem: Optional[bool] = None
159
+ has_photo: bool
160
+ dc_id: Optional[int] = None
161
+
162
+ class GiftRelationInfo(BaseModel):
163
+ last_gift_date: Optional[str] = None
164
+ from_user_id: int
165
+ from_first_name: Optional[str] = None
166
+ from_last_name: Optional[str] = None
167
+ from_main_username: Optional[str] = Field(default=None, alias="from_mainUsername")
168
+ from_is_active: bool
169
+ to_user_id: int
170
+ to_first_name: Optional[str] = None
171
+ to_last_name: Optional[str] = None
172
+ to_main_username: Optional[str] = Field(default=None, alias="to_mainUsername")
173
+ to_is_active: bool
174
+ model_config = {"populate_by_name": True}
175
+
176
+ class StickerInfo(BaseModel):
177
+ sticker_set_id: int
178
+ last_seen: str
179
+ min_seen: str
180
+ resolved: Optional[str] = None
181
+ title: Optional[str] = None
182
+ short_name: Optional[str] = None
183
+ stickers_count: Optional[int] = None
184
+
185
+ class UCommonGroupInfo(BaseModel):
186
+ user_id: int
187
+ common_groups: int
188
+ first_name: Optional[str] = None
189
+ last_name: Optional[str] = None
190
+ username: Optional[str] = None
191
+ is_user_active: bool
192
+
193
+ class UsernameUsageModel(BaseModel):
194
+ actual_users: Optional[list[ResolvedUser]] = Field(default=None, alias="actualUsers")
195
+ usage_by_users_in_the_past: Optional[list[ResolvedUser]] = Field(default=None, alias="usageByUsersInThePast")
196
+ actual_groups_or_channels: Optional[list[ChatInfoExt]] = Field(default=None, alias="actualGroupsOrChannels")
197
+ mention_by_channel_or_group_desc: Optional[list[ChatInfoExt]] = Field(default=None, alias="mentionByChannelOrGroupDesc")
198
+ model_config = {"populate_by_name": True}
199
+
200
+ class WhoWroteText(BaseModel):
201
+ message_id: int
202
+ user_id: int
203
+ date: str
204
+ name: Optional[str] = None
205
+ username: Optional[str] = None
206
+ is_active: bool
207
+ group: ChatInfoExt
208
+ text: Optional[str] = None
209
+
210
+ class PingResult(BaseModel):
211
+ request_ping: str
212
+ responce_ping: float
213
+
214
+ # ─────────────────────────────────────────────────────────────────────────────
215
+ # Models — API response wrappers
216
+ # ─────────────────────────────────────────────────────────────────────────────
217
+
218
+ class ResolvedUserResponse(BaseModel):
219
+ success: bool
220
+ tech: TechInfo
221
+ data: Optional[list[ResolvedUser]] = None
222
+
223
+ class UserStatsMinResponse(BaseModel):
224
+ success: bool
225
+ tech: TechInfo
226
+ data: Optional[UserStatsMin] = None
227
+
228
+ class UserStatsResponse(BaseModel):
229
+ success: bool
230
+ tech: TechInfo
231
+ data: Optional[UserStats] = None
232
+
233
+ class UserMsgPagedResponse(BaseModel):
234
+ success: bool
235
+ tech: TechInfo
236
+ paging: Paging
237
+ data: Optional[list[UserMsg]] = None
238
+
239
+ class UserNameInfoResponse(BaseModel):
240
+ success: bool
241
+ tech: TechInfo
242
+ data: Optional[list[UserNameInfo]] = None
243
+
244
+ class UsrChatInfoResponse(BaseModel):
245
+ success: bool
246
+ tech: TechInfo
247
+ data: Optional[list[UsrChatInfo]] = None
248
+
249
+ class GroupMemberResponse(BaseModel):
250
+ success: bool
251
+ tech: TechInfo
252
+ data: Optional[list[GroupMember]] = None
253
+
254
+ class GiftRelationResponse(BaseModel):
255
+ success: bool
256
+ tech: TechInfo
257
+ data: Optional[list[GiftRelationInfo]] = None
258
+
259
+ class StickerInfoResponse(BaseModel):
260
+ success: bool
261
+ tech: TechInfo
262
+ data: Optional[list[StickerInfo]] = None
263
+
264
+ class UCommonGroupInfoResponse(BaseModel):
265
+ success: bool
266
+ tech: TechInfo
267
+ data: Optional[list[UCommonGroupInfo]] = None
268
+
269
+ class UsernameUsageResponse(BaseModel):
270
+ success: bool
271
+ tech: TechInfo
272
+ data: Optional[UsernameUsageModel] = None
273
+
274
+ class ChatInfoExtResponse(BaseModel):
275
+ success: bool
276
+ tech: TechInfo
277
+ data: Optional[list[ChatInfoExt]] = None
278
+
279
+ class WhoWroteTextPaged(BaseModel):
280
+ total: int
281
+ data: list[WhoWroteText]
282
+ is_last_page: Optional[bool] = Field(default=None, alias="isLastPage")
283
+ page_size: Optional[int] = Field(default=None, alias="pageSize")
284
+ current_page: Optional[int] = Field(default=None, alias="currentPage")
285
+ total_pages: Optional[int] = Field(default=None, alias="totalPages")
286
+ is_sliding: Optional[bool] = Field(default=None, alias="isSliding")
287
+ model_config = {"populate_by_name": True}
288
+
289
+ class WhoWroteTextResponse(BaseModel):
290
+ success: bool
291
+ tech: TechInfo
292
+ data: Optional[WhoWroteTextPaged] = None
293
+
294
+ # ─────────────────────────────────────────────────────────────────────────────
295
+ # Helpers
296
+ # ─────────────────────────────────────────────────────────────────────────────
297
+
298
+ BASE_URL = "https://funstat.info/api/v1"
299
+
300
+ def _clean_username(username: str) -> str:
301
+ username = username.strip()
302
+ username = re.sub(r"^https?://t\.me/", "", username)
303
+ username = re.sub(r"^t\.me/", "", username)
304
+ return username.lstrip("@").split("/")[0].split("?")[0]
305
+
306
+ def _make_empty_tech() -> TechInfo:
307
+ return TechInfo(request_cost=0, current_ballance=0, request_duration="")
308
+
309
+ def _wrap(payload: dict | None, model: type[BaseModel]) -> BaseModel | None:
310
+ if payload is None:
311
+ return None
312
+ if not payload.get("success", True):
313
+ logger.warning("API returned success=false: %s", payload)
314
+ return model.model_validate(payload)
315
+
316
+ def _normalise_stats_min(payload: dict | None) -> UserStatsMinResponse | None:
317
+ if payload is None:
318
+ return None
319
+ if "success" not in payload:
320
+ return UserStatsMinResponse(
321
+ success=True,
322
+ tech=_make_empty_tech(),
323
+ data=UserStatsMin.model_validate(payload),
324
+ )
325
+ return UserStatsMinResponse.model_validate(payload)
326
+
327
+ def _extract_tech(result: dict | None) -> TechInfo | None:
328
+ if result and "tech" in result:
329
+ return TechInfo.model_validate(result["tech"])
330
+ return None
331
+
332
+ # ─────────────────────────────────────────────────────────────────────────────
333
+ # Sync client
334
+ # ─────────────────────────────────────────────────────────────────────────────
335
+
336
+ class FunstatClient:
337
+ """Synchronous Funstat API client. Uses requests, no async/await needed.
338
+
339
+ Example:
340
+ fs = FunstatClient("mytoken")
341
+ print(fs.stats("durov"))
342
+ print(fs.get_group_members("https://t.me/mychat"))
343
+ """
344
+
345
+ def __init__(self, token: str) -> None:
346
+ import requests as _requests
347
+ self.token = token
348
+ self._session = _requests.Session()
349
+ self._session.headers["Authorization"] = f"Bearer {token}"
350
+ def close(self) -> None:
351
+ """Close the underlying HTTP session."""
352
+ self._session.close()
353
+
354
+ def __enter__(self) -> "FunstatClient":
355
+ return self
356
+
357
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
358
+ self.close()
359
+
360
+ def _get(self, path: str, **params: Any) -> dict | None:
361
+ url = f"{BASE_URL}/{path.lstrip('/')}"
362
+ r = self._session.get(url, params=params or None)
363
+ if r.status_code == 200:
364
+ return r.json()
365
+ raise ApiError(r.status_code, path)
366
+
367
+ # ── internal resolvers ────────────────────────────────────────────────────
368
+
369
+ def _resolve_user(self, user: int | str) -> int:
370
+ if isinstance(user, int):
371
+ return user
372
+ clean = _clean_username(str(user))
373
+ if clean.lstrip("-").isdigit():
374
+ return int(clean)
375
+ result = self._get("users/resolve_username", name=clean)
376
+ if result and result.get("data"):
377
+ return result["data"][0]["id"]
378
+ raise ResolveError(f"User not found: {user!r}")
379
+
380
+ def _resolve_group(self, group: int | str) -> int:
381
+ if isinstance(group, int):
382
+ return group
383
+ clean = _clean_username(str(group))
384
+ if clean.lstrip("-").isdigit():
385
+ return int(clean)
386
+ result = self._get("users/username_usage", username=clean)
387
+ chats = ((result or {}).get("data") or {}).get("actualGroupsOrChannels") or []
388
+ if chats:
389
+ return chats[0]["id"]
390
+ raise ResolveError(f"Group not found: {group!r}")
391
+
392
+ # ── public methods ────────────────────────────────────────────────────────
393
+
394
+ def ping(self) -> PingResult | None:
395
+ t0 = time.time()
396
+ result = self._get("users/resolve_username", name="q")
397
+ elapsed = time.time() - t0
398
+ if result and "tech" in result:
399
+ return PingResult(request_ping=result["tech"].get("request_duration", ""), responce_ping=elapsed)
400
+ return None
401
+
402
+ def get_balance(self) -> TechInfo | None:
403
+ return _extract_tech(self._get("users/resolve_username", name="q"))
404
+
405
+ def resolve_username(self, username: str) -> ResolvedUserResponse | None:
406
+ return _wrap(self._get("users/resolve_username", name=_clean_username(username)), ResolvedUserResponse)
407
+
408
+ def basic_info_by_id(self, ids: int | str | list[int | str]) -> ResolvedUserResponse | None:
409
+ if not isinstance(ids, list):
410
+ ids = [ids]
411
+ resolved = [self._resolve_user(i) for i in ids]
412
+ return _wrap(self._get("users/basic_info_by_id", id=resolved), ResolvedUserResponse)
413
+
414
+ def stats_min(self, user: int | str) -> UserStatsMinResponse | None:
415
+ return _normalise_stats_min(self._get(f"users/{self._resolve_user(user)}/stats_min"))
416
+
417
+ def stats(self, user: int | str) -> UserStatsResponse | None:
418
+ return _wrap(self._get(f"users/{self._resolve_user(user)}/stats"), UserStatsResponse)
419
+
420
+ def messages_count(self, user: int | str) -> int:
421
+ return self._get(f"users/{self._resolve_user(user)}/messages_count") or 0
422
+
423
+ def groups_count(self, user: int | str, only_msg: bool = False) -> int:
424
+ return self._get(f"users/{self._resolve_user(user)}/groups_count", onlyMsg=str(only_msg).lower()) or 0
425
+
426
+ def get_messages(self, user: int | str, filter: str | None = None, group: int | str | None = None, limit: int = 20, page: int = 1) -> UserMsgPagedResponse | None:
427
+ params: dict = {"page": page, "pageSize": limit}
428
+ if filter:
429
+ params["text_contains"] = filter
430
+ if group is not None:
431
+ params["group_id"] = self._resolve_group(group)
432
+ return _wrap(self._get(f"users/{self._resolve_user(user)}/messages", **params), UserMsgPagedResponse)
433
+
434
+ def get_chats(self, user: int | str) -> UsrChatInfoResponse | None:
435
+ return _wrap(self._get(f"users/{self._resolve_user(user)}/groups"), UsrChatInfoResponse)
436
+
437
+ def get_names(self, user: int | str) -> UserNameInfoResponse | None:
438
+ return _wrap(self._get(f"users/{self._resolve_user(user)}/names"), UserNameInfoResponse)
439
+
440
+ def get_usernames(self, user: int | str) -> UserNameInfoResponse | None:
441
+ return _wrap(self._get(f"users/{self._resolve_user(user)}/usernames"), UserNameInfoResponse)
442
+
443
+ def rep(self, user: int | str) -> dict | None:
444
+ return self._get("users/reputation", id=self._resolve_user(user))
445
+
446
+ def common_groups(self, user: int | str) -> UCommonGroupInfoResponse | None:
447
+ return _wrap(self._get(f"users/{self._resolve_user(user)}/common_groups_stat"), UCommonGroupInfoResponse)
448
+
449
+ def get_stickers(self, user: int | str) -> StickerInfoResponse | None:
450
+ return _wrap(self._get(f"users/{self._resolve_user(user)}/stickers"), StickerInfoResponse)
451
+
452
+ def get_gifts(self, user: int | str, limit: int = 20, page: int = 1) -> GiftRelationResponse | None:
453
+ return _wrap(self._get(f"users/{self._resolve_user(user)}/gifts_relation", page=page, pageSize=limit), GiftRelationResponse)
454
+
455
+ def username_usage(self, username: str) -> UsernameUsageResponse | None:
456
+ return _wrap(self._get("users/username_usage", username=_clean_username(username)), UsernameUsageResponse)
457
+
458
+ def common_groups_for_users(self, ids: list[int | str]) -> ChatInfoExtResponse | None:
459
+ resolved = [self._resolve_user(i) for i in ids]
460
+ return _wrap(self._get("groups/common_groups", id=resolved), ChatInfoExtResponse)
461
+
462
+ def get_group_info(self, group: int | str) -> dict | None:
463
+ return self._get(f"groups/{self._resolve_group(group)}")
464
+
465
+ def get_group_members(self, group: int | str) -> GroupMemberResponse | None:
466
+ return _wrap(self._get(f"groups/{self._resolve_group(group)}/members"), GroupMemberResponse)
467
+
468
+ def search_text(self, query: str, page: int = 1, page_size: int = 20) -> WhoWroteTextResponse | None:
469
+ return _wrap(self._get("text/search", input=query, page=page, pageSize=page_size), WhoWroteTextResponse)
470
+
471
+ # ─────────────────────────────────────────────────────────────────────────────
472
+ # Async client
473
+ # ─────────────────────────────────────────────────────────────────────────────
474
+
475
+ class AsyncFunstatClient:
476
+ """Asynchronous Funstat API client. Uses httpx, all methods must be awaited.
477
+
478
+ Example:
479
+ fs = AsyncFunstatClient("mytoken")
480
+ print(await fs.stats("durov"))
481
+ print(await fs.get_group_members("https://t.me/mychat"))
482
+ """
483
+
484
+ def __init__(self, token: str) -> None:
485
+ import httpx
486
+ self.token = token
487
+ self._client = httpx.AsyncClient(headers={"Authorization": f"Bearer {token}"})
488
+ async def close(self) -> None:
489
+ """Close the underlying HTTP client and release connections."""
490
+ await self._client.aclose()
491
+
492
+ async def __aenter__(self) -> "AsyncFunstatClient":
493
+ return self
494
+
495
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
496
+ await self.close()
497
+
498
+ async def _get(self, path: str, **params: Any) -> dict | None:
499
+ url = f"{BASE_URL}/{path.lstrip('/')}"
500
+ r = await self._client.get(url, params=params or None)
501
+ if r.status_code == 200:
502
+ return r.json()
503
+ raise ApiError(r.status_code, path)
504
+
505
+ # ── internal resolvers ────────────────────────────────────────────────────
506
+
507
+ async def _resolve_user(self, user: int | str) -> int:
508
+ if isinstance(user, int):
509
+ return user
510
+ clean = _clean_username(str(user))
511
+ if clean.lstrip("-").isdigit():
512
+ return int(clean)
513
+ result = await self._get("users/resolve_username", name=clean)
514
+ if result and result.get("data"):
515
+ return result["data"][0]["id"]
516
+ raise ResolveError(f"User not found: {user!r}")
517
+
518
+ async def _resolve_group(self, group: int | str) -> int:
519
+ if isinstance(group, int):
520
+ return group
521
+ clean = _clean_username(str(group))
522
+ if clean.lstrip("-").isdigit():
523
+ return int(clean)
524
+ result = await self._get("users/username_usage", username=clean)
525
+ chats = ((result or {}).get("data") or {}).get("actualGroupsOrChannels") or []
526
+ if chats:
527
+ return chats[0]["id"]
528
+ raise ResolveError(f"Group not found: {group!r}")
529
+
530
+ # ── public methods ────────────────────────────────────────────────────────
531
+
532
+ async def ping(self) -> PingResult | None:
533
+ t0 = time.time()
534
+ result = await self._get("users/resolve_username", name="q")
535
+ elapsed = time.time() - t0
536
+ if result and "tech" in result:
537
+ return PingResult(request_ping=result["tech"].get("request_duration", ""), responce_ping=elapsed)
538
+ return None
539
+
540
+ async def get_balance(self) -> TechInfo | None:
541
+ return _extract_tech(await self._get("users/resolve_username", name="q"))
542
+
543
+ async def resolve_username(self, username: str) -> ResolvedUserResponse | None:
544
+ return _wrap(await self._get("users/resolve_username", name=_clean_username(username)), ResolvedUserResponse)
545
+
546
+ async def basic_info_by_id(self, ids: int | str | list[int | str]) -> ResolvedUserResponse | None:
547
+ import asyncio
548
+ if not isinstance(ids, list):
549
+ ids = [ids]
550
+ resolved = list(await asyncio.gather(*[self._resolve_user(i) for i in ids]))
551
+ return _wrap(await self._get("users/basic_info_by_id", id=resolved), ResolvedUserResponse)
552
+
553
+ async def stats_min(self, user: int | str) -> UserStatsMinResponse | None:
554
+ return _normalise_stats_min(await self._get(f"users/{await self._resolve_user(user)}/stats_min"))
555
+
556
+ async def stats(self, user: int | str) -> UserStatsResponse | None:
557
+ return _wrap(await self._get(f"users/{await self._resolve_user(user)}/stats"), UserStatsResponse)
558
+
559
+ async def messages_count(self, user: int | str) -> int:
560
+ return await self._get(f"users/{await self._resolve_user(user)}/messages_count") or 0
561
+
562
+ async def groups_count(self, user: int | str, only_msg: bool = False) -> int:
563
+ return await self._get(f"users/{await self._resolve_user(user)}/groups_count", onlyMsg=str(only_msg).lower()) or 0
564
+
565
+ async def get_messages(self, user: int | str, filter: str | None = None, group: int | str | None = None, limit: int = 20, page: int = 1) -> UserMsgPagedResponse | None:
566
+ params: dict = {"page": page, "pageSize": limit}
567
+ if filter:
568
+ params["text_contains"] = filter
569
+ if group is not None:
570
+ params["group_id"] = await self._resolve_group(group)
571
+ return _wrap(await self._get(f"users/{await self._resolve_user(user)}/messages", **params), UserMsgPagedResponse)
572
+
573
+ async def get_chats(self, user: int | str) -> UsrChatInfoResponse | None:
574
+ return _wrap(await self._get(f"users/{await self._resolve_user(user)}/groups"), UsrChatInfoResponse)
575
+
576
+ async def get_names(self, user: int | str) -> UserNameInfoResponse | None:
577
+ return _wrap(await self._get(f"users/{await self._resolve_user(user)}/names"), UserNameInfoResponse)
578
+
579
+ async def get_usernames(self, user: int | str) -> UserNameInfoResponse | None:
580
+ return _wrap(await self._get(f"users/{await self._resolve_user(user)}/usernames"), UserNameInfoResponse)
581
+
582
+ async def rep(self, user: int | str) -> dict | None:
583
+ return await self._get("users/reputation", id=await self._resolve_user(user))
584
+
585
+ async def common_groups(self, user: int | str) -> UCommonGroupInfoResponse | None:
586
+ return _wrap(await self._get(f"users/{await self._resolve_user(user)}/common_groups_stat"), UCommonGroupInfoResponse)
587
+
588
+ async def get_stickers(self, user: int | str) -> StickerInfoResponse | None:
589
+ return _wrap(await self._get(f"users/{await self._resolve_user(user)}/stickers"), StickerInfoResponse)
590
+
591
+ async def get_gifts(self, user: int | str, limit: int = 20, page: int = 1) -> GiftRelationResponse | None:
592
+ return _wrap(await self._get(f"users/{await self._resolve_user(user)}/gifts_relation", page=page, pageSize=limit), GiftRelationResponse)
593
+
594
+ async def username_usage(self, username: str) -> UsernameUsageResponse | None:
595
+ return _wrap(await self._get("users/username_usage", username=_clean_username(username)), UsernameUsageResponse)
596
+
597
+ async def common_groups_for_users(self, ids: list[int | str]) -> ChatInfoExtResponse | None:
598
+ import asyncio
599
+ resolved = list(await asyncio.gather(*[self._resolve_user(i) for i in ids]))
600
+ return _wrap(await self._get("groups/common_groups", id=resolved), ChatInfoExtResponse)
601
+
602
+ async def get_group_info(self, group: int | str) -> dict | None:
603
+ return await self._get(f"groups/{await self._resolve_group(group)}")
604
+
605
+ async def get_group_members(self, group: int | str) -> GroupMemberResponse | None:
606
+ return _wrap(await self._get(f"groups/{await self._resolve_group(group)}/members"), GroupMemberResponse)
607
+
608
+ async def search_text(self, query: str, page: int = 1, page_size: int = 20) -> WhoWroteTextResponse | None:
609
+ return _wrap(await self._get("text/search", input=query, page=page, pageSize=page_size), WhoWroteTextResponse)
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "funstat-api"
7
+ version = "0.1.0"
8
+ description = "Sync and async Python client for the Funstat API (Telegram user/group statistics)"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "meiji", email = "chizumeiji@gmail.com" }
14
+ ]
15
+ keywords = ["telegram", "funstat", "api", "client", "statistics"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ ]
28
+ dependencies = [
29
+ "pydantic>=2.0",
30
+ "requests>=2.28",
31
+ "httpx>=0.24",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/chizumeiji/funstat-api"
36
+ "Bug Tracker" = "https://github.com/chizumeiji/funstat-api/issues"
@@ -0,0 +1,82 @@
1
+ # funstat-api
2
+
3
+ Python client for the [Funstat](http://funstat.in/?start=0108FC1E9BEF75617466) API — Telegram user and group statistics.
4
+
5
+ Supports both **sync** and **async** usage.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install funstat-api
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ### Sync
16
+
17
+ ```python
18
+ from funstat_api import FunstatClient
19
+
20
+ fs = FunstatClient("your_token")
21
+
22
+ # Get user stats
23
+ stats = fs.stats("durov")
24
+ print(stats.data.total_msg_count)
25
+
26
+ # Get group members
27
+ members = fs.get_group_members("https://t.me/mychat")
28
+
29
+ # Use as context manager
30
+ with FunstatClient("your_token") as fs:
31
+ print(fs.ping())
32
+ ```
33
+
34
+ ### Async
35
+
36
+ ```python
37
+ import asyncio
38
+ from funstat_api import AsyncFunstatClient
39
+
40
+ async def main():
41
+ async with AsyncFunstatClient("your_token") as fs:
42
+ stats = await fs.stats("durov")
43
+ print(stats.data.total_msg_count)
44
+
45
+ asyncio.run(main())
46
+ ```
47
+
48
+ ## Available Methods
49
+
50
+ | Method | Description |
51
+ |--------|-------------|
52
+ | `ping()` | Check API availability and latency |
53
+ | `get_balance()` | Get current token balance |
54
+ | `resolve_username(username)` | Resolve username to user info |
55
+ | `basic_info_by_id(ids)` | Get basic info by user ID(s) |
56
+ | `stats_min(user)` | Get minimal user statistics |
57
+ | `stats(user)` | Get full user statistics |
58
+ | `messages_count(user)` | Get total message count |
59
+ | `groups_count(user)` | Get number of groups |
60
+ | `get_messages(user, ...)` | Get paginated message list |
61
+ | `get_chats(user)` | Get user's chat list |
62
+ | `get_names(user)` | Get name history |
63
+ | `get_usernames(user)` | Get username history |
64
+ | `get_stickers(user)` | Get used sticker packs |
65
+ | `get_gifts(user)` | Get gift relations |
66
+ | `common_groups(user)` | Get common groups stats |
67
+ | `username_usage(username)` | Who uses or used a username |
68
+ | `get_group_info(group)` | Get group/channel info |
69
+ | `get_group_members(group)` | Get group members |
70
+ | `search_text(query)` | Search messages by text |
71
+
72
+ `user` and `group` arguments accept: numeric ID, `@username`, or `https://t.me/...` link.
73
+
74
+ ## Dependencies
75
+
76
+ - `pydantic >= 2.0`
77
+ - `requests >= 2.28` (sync client)
78
+ - `httpx >= 0.24` (async client)
79
+
80
+ ## License
81
+
82
+ MIT