xselfbot 4.0.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.
Files changed (47) hide show
  1. xselfbot-4.0.0/LICENSE +21 -0
  2. xselfbot-4.0.0/PKG-INFO +138 -0
  3. xselfbot-4.0.0/README.md +112 -0
  4. xselfbot-4.0.0/setup.cfg +4 -0
  5. xselfbot-4.0.0/setup.py +33 -0
  6. xselfbot-4.0.0/tests/test_fork.py +356 -0
  7. xselfbot-4.0.0/xselfbot/__init__.py +34 -0
  8. xselfbot-4.0.0/xselfbot/_captcha/__init__.py +2 -0
  9. xselfbot-4.0.0/xselfbot/_captcha/base.py +111 -0
  10. xselfbot-4.0.0/xselfbot/_captcha/capsolver.py +95 -0
  11. xselfbot-4.0.0/xselfbot/bookmark.py +64 -0
  12. xselfbot-4.0.0/xselfbot/client/client.py +4527 -0
  13. xselfbot-4.0.0/xselfbot/client/gql.py +730 -0
  14. xselfbot-4.0.0/xselfbot/client/v11.py +512 -0
  15. xselfbot-4.0.0/xselfbot/community.py +282 -0
  16. xselfbot-4.0.0/xselfbot/constants.py +268 -0
  17. xselfbot-4.0.0/xselfbot/content_disclosure.py +104 -0
  18. xselfbot-4.0.0/xselfbot/errors.py +110 -0
  19. xselfbot-4.0.0/xselfbot/geo.py +82 -0
  20. xselfbot-4.0.0/xselfbot/group.py +259 -0
  21. xselfbot-4.0.0/xselfbot/guest/__init__.py +3 -0
  22. xselfbot-4.0.0/xselfbot/guest/client.py +411 -0
  23. xselfbot-4.0.0/xselfbot/guest/tweet.py +235 -0
  24. xselfbot-4.0.0/xselfbot/guest/user.py +196 -0
  25. xselfbot-4.0.0/xselfbot/list.py +255 -0
  26. xselfbot-4.0.0/xselfbot/media.py +346 -0
  27. xselfbot-4.0.0/xselfbot/message.py +143 -0
  28. xselfbot-4.0.0/xselfbot/notification.py +47 -0
  29. xselfbot-4.0.0/xselfbot/py.typed +0 -0
  30. xselfbot-4.0.0/xselfbot/streaming.py +266 -0
  31. xselfbot-4.0.0/xselfbot/trend.py +93 -0
  32. xselfbot-4.0.0/xselfbot/tweet.py +790 -0
  33. xselfbot-4.0.0/xselfbot/ui_metrics/__init__.py +25 -0
  34. xselfbot-4.0.0/xselfbot/ui_metrics/dom.py +52 -0
  35. xselfbot-4.0.0/xselfbot/user.py +532 -0
  36. xselfbot-4.0.0/xselfbot/utils.py +427 -0
  37. xselfbot-4.0.0/xselfbot/x_client_transaction/__init__.py +8 -0
  38. xselfbot-4.0.0/xselfbot/x_client_transaction/cubic_curve.py +48 -0
  39. xselfbot-4.0.0/xselfbot/x_client_transaction/interpolate.py +23 -0
  40. xselfbot-4.0.0/xselfbot/x_client_transaction/rotation.py +27 -0
  41. xselfbot-4.0.0/xselfbot/x_client_transaction/transaction.py +170 -0
  42. xselfbot-4.0.0/xselfbot/x_client_transaction/utils.py +84 -0
  43. xselfbot-4.0.0/xselfbot.egg-info/PKG-INFO +138 -0
  44. xselfbot-4.0.0/xselfbot.egg-info/SOURCES.txt +45 -0
  45. xselfbot-4.0.0/xselfbot.egg-info/dependency_links.txt +1 -0
  46. xselfbot-4.0.0/xselfbot.egg-info/requires.txt +8 -0
  47. xselfbot-4.0.0/xselfbot.egg-info/top_level.txt +1 -0
xselfbot-4.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 d60
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,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: xselfbot
3
+ Version: 4.0.0
4
+ Summary: X client for python — posting, reading, search and streaming, no API key.
5
+ Home-page: https://github.com/Tky567/xselfbot
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: httpx[socks]
11
+ Requires-Dist: filetype
12
+ Requires-Dist: beautifulsoup4
13
+ Requires-Dist: pyotp
14
+ Requires-Dist: lxml
15
+ Requires-Dist: webvtt-py
16
+ Requires-Dist: m3u8
17
+ Requires-Dist: Js2Py-3.13
18
+ Dynamic: description
19
+ Dynamic: description-content-type
20
+ Dynamic: home-page
21
+ Dynamic: license
22
+ Dynamic: license-file
23
+ Dynamic: requires-dist
24
+ Dynamic: requires-python
25
+ Dynamic: summary
26
+
27
+ # XSelfBot
28
+
29
+ X client for Python. Posts, reads, searches and streams over a cookie session, with no
30
+ API key.
31
+
32
+ ```
33
+ pip install git+https://github.com/Tky567/xselfbot.git@main
34
+ ```
35
+
36
+ ```
37
+ pip install xselfbot
38
+ ```
39
+
40
+ Requires Python 3.10+.
41
+
42
+ ## Session
43
+
44
+ The cookie session is the supported path. Export the cookies of a signed-in browser
45
+ session (any Netscape-format jar works) and hand them to the client:
46
+
47
+ ```python
48
+ import asyncio
49
+ from xselfbot import Client
50
+
51
+ client = Client('en-US')
52
+ client.load_cookies('cookies.json')
53
+
54
+ async def main():
55
+ print(await client.identity())
56
+ # {'user_id': '…', 'screen_name': '…', 'name': '…', 'avatar': '…'}
57
+
58
+ asyncio.run(main())
59
+ ```
60
+
61
+ `login()` by password is still in the tree, but it is the path that gets accounts
62
+ flagged. See [ToProtectYourAccount.md](ToProtectYourAccount.md) and
63
+ [ratelimits.md](ratelimits.md).
64
+
65
+ ## Post
66
+
67
+ ```python
68
+ media_ids = [await client.upload_media('media1.jpg')]
69
+ await client.create_tweet('Example', media_ids=media_ids)
70
+ ```
71
+
72
+ Content disclosure, the way the web composer sends it:
73
+
74
+ ```python
75
+ await client.create_tweet('Rendered overnight', made_with_ai=True)
76
+ await client.create_tweet('Partner drop', paid_partnership=True)
77
+
78
+ await client.add_content_disclosure(tweet_id, made_with_ai=True)
79
+ await client.delete_content_disclosure(tweet_id)
80
+ ```
81
+
82
+ Read it back — the label is only serialised when the read query asks for it, which the
83
+ default feature sets do:
84
+
85
+ ```python
86
+ from xselfbot import applied_disclosures
87
+
88
+ tweet = await client.get_tweet_by_id(tweet_id)
89
+ applied_disclosures(tweet) # {'paid_partnership': False, 'made_with_ai': True}
90
+ ```
91
+
92
+ ## Read
93
+
94
+ ```python
95
+ tweets = await client.search_tweet('python', 'Latest')
96
+ for tweet in tweets:
97
+ print(tweet.user.name, tweet.text, tweet.created_at)
98
+
99
+ tweets = await client.get_user_tweets('123456', 'Tweets')
100
+ trends = await client.get_trends('trending')
101
+ ```
102
+
103
+ ## Message and stream
104
+
105
+ ```python
106
+ await client.send_dm('123456789', 'Hello')
107
+
108
+ from xselfbot.streaming import Topic
109
+ ```
110
+
111
+ ## Layout
112
+
113
+ | path | what |
114
+ |---|---|
115
+ | `xselfbot/client/(client|gql|v11).py` | session, GraphQL and v1.1 transport |
116
+ | `xselfbot/tweet.py`, `user.py`, `message.py` | entities |
117
+ | `xselfbot/content_disclosure.py` | the Paid partnership / Made with AI labels |
118
+ | `xselfbot/x_client_transaction/` | `X-Client-Transaction-Id` builder, with a fallback |
119
+ | `xselfbot/guest/` | logged-out client |
120
+ | `docs/` | Sphinx sources |
121
+ | `tests/` | offline checks, no network |
122
+
123
+ ## Tests
124
+
125
+ ```
126
+ python -m pytest tests -q
127
+ ```
128
+
129
+ ## Credit
130
+
131
+ This library began as a fork of the [Twikit](https://github.com/d60/twikit) project, and the shape of the client —
132
+ the entity model, the GraphQL endpoint table, the captcha hook — is that project's work.
133
+ Credit for the original design belongs to its author; what is here is the same library
134
+ carried through the 2026 x.com build and renamed so the two can be told apart.
135
+
136
+ ## Licence
137
+
138
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,112 @@
1
+ # XSelfBot
2
+
3
+ X client for Python. Posts, reads, searches and streams over a cookie session, with no
4
+ API key.
5
+
6
+ ```
7
+ pip install git+https://github.com/Tky567/xselfbot.git@main
8
+ ```
9
+
10
+ ```
11
+ pip install xselfbot
12
+ ```
13
+
14
+ Requires Python 3.10+.
15
+
16
+ ## Session
17
+
18
+ The cookie session is the supported path. Export the cookies of a signed-in browser
19
+ session (any Netscape-format jar works) and hand them to the client:
20
+
21
+ ```python
22
+ import asyncio
23
+ from xselfbot import Client
24
+
25
+ client = Client('en-US')
26
+ client.load_cookies('cookies.json')
27
+
28
+ async def main():
29
+ print(await client.identity())
30
+ # {'user_id': '…', 'screen_name': '…', 'name': '…', 'avatar': '…'}
31
+
32
+ asyncio.run(main())
33
+ ```
34
+
35
+ `login()` by password is still in the tree, but it is the path that gets accounts
36
+ flagged. See [ToProtectYourAccount.md](ToProtectYourAccount.md) and
37
+ [ratelimits.md](ratelimits.md).
38
+
39
+ ## Post
40
+
41
+ ```python
42
+ media_ids = [await client.upload_media('media1.jpg')]
43
+ await client.create_tweet('Example', media_ids=media_ids)
44
+ ```
45
+
46
+ Content disclosure, the way the web composer sends it:
47
+
48
+ ```python
49
+ await client.create_tweet('Rendered overnight', made_with_ai=True)
50
+ await client.create_tweet('Partner drop', paid_partnership=True)
51
+
52
+ await client.add_content_disclosure(tweet_id, made_with_ai=True)
53
+ await client.delete_content_disclosure(tweet_id)
54
+ ```
55
+
56
+ Read it back — the label is only serialised when the read query asks for it, which the
57
+ default feature sets do:
58
+
59
+ ```python
60
+ from xselfbot import applied_disclosures
61
+
62
+ tweet = await client.get_tweet_by_id(tweet_id)
63
+ applied_disclosures(tweet) # {'paid_partnership': False, 'made_with_ai': True}
64
+ ```
65
+
66
+ ## Read
67
+
68
+ ```python
69
+ tweets = await client.search_tweet('python', 'Latest')
70
+ for tweet in tweets:
71
+ print(tweet.user.name, tweet.text, tweet.created_at)
72
+
73
+ tweets = await client.get_user_tweets('123456', 'Tweets')
74
+ trends = await client.get_trends('trending')
75
+ ```
76
+
77
+ ## Message and stream
78
+
79
+ ```python
80
+ await client.send_dm('123456789', 'Hello')
81
+
82
+ from xselfbot.streaming import Topic
83
+ ```
84
+
85
+ ## Layout
86
+
87
+ | path | what |
88
+ |---|---|
89
+ | `xselfbot/client/(client|gql|v11).py` | session, GraphQL and v1.1 transport |
90
+ | `xselfbot/tweet.py`, `user.py`, `message.py` | entities |
91
+ | `xselfbot/content_disclosure.py` | the Paid partnership / Made with AI labels |
92
+ | `xselfbot/x_client_transaction/` | `X-Client-Transaction-Id` builder, with a fallback |
93
+ | `xselfbot/guest/` | logged-out client |
94
+ | `docs/` | Sphinx sources |
95
+ | `tests/` | offline checks, no network |
96
+
97
+ ## Tests
98
+
99
+ ```
100
+ python -m pytest tests -q
101
+ ```
102
+
103
+ ## Credit
104
+
105
+ This library began as a fork of the [Twikit](https://github.com/d60/twikit) project, and the shape of the client —
106
+ the entity model, the GraphQL endpoint table, the captcha hook — is that project's work.
107
+ Credit for the original design belongs to its author; what is here is the same library
108
+ carried through the 2026 x.com build and renamed so the two can be told apart.
109
+
110
+ ## Licence
111
+
112
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,33 @@
1
+ import re
2
+
3
+ from setuptools import setup
4
+
5
+ with open('README.md', encoding='utf-8') as f:
6
+ long_description = f.read()
7
+
8
+ with open('./xselfbot/__init__.py') as f:
9
+ version = re.findall(r"__version__ = '(.+)'", f.read())[0]
10
+
11
+
12
+ setup(
13
+ name='xselfbot',
14
+ version=version,
15
+ install_requires=[
16
+ 'httpx[socks]',
17
+ 'filetype',
18
+ 'beautifulsoup4',
19
+ 'pyotp',
20
+ 'lxml',
21
+ 'webvtt-py',
22
+ 'm3u8',
23
+ 'Js2Py-3.13'
24
+ ],
25
+ python_requires='>=3.10',
26
+ description='X client for python — posting, reading, search and streaming, no API key.',
27
+ long_description=long_description,
28
+ long_description_content_type='text/markdown',
29
+ license='MIT',
30
+ url='https://github.com/Tky567/xselfbot',
31
+ package_data={'xselfbot': ['py.typed']}
32
+ )
33
+
@@ -0,0 +1,356 @@
1
+ """Offline checks for the 2026 fork. No network, no cookies.
2
+
3
+ python -m pytest tests -q
4
+ """
5
+
6
+ import asyncio
7
+ import json
8
+
9
+ from xselfbot import Client, Tweet
10
+ from xselfbot.client.gql import GQLClient
11
+ from xselfbot.content_disclosure import applied_disclosures, build_content_disclosure
12
+
13
+
14
+ # ---------------------------------------------------------------- disclosure
15
+
16
+
17
+ def test_build_disclosure_none():
18
+ assert build_content_disclosure() is None
19
+ assert build_content_disclosure(False, False) is None
20
+
21
+
22
+ def test_build_disclosure_ai():
23
+ assert build_content_disclosure(made_with_ai=True) == {
24
+ 'ai_generated_disclosure': {
25
+ 'has_ai_generated_media': True,
26
+ 'ai_generated_detection_source': 'UserDeclared',
27
+ }
28
+ }
29
+
30
+
31
+ def test_build_disclosure_paid():
32
+ assert build_content_disclosure(paid_partnership=True) == {
33
+ 'advertising_disclosure': {'is_paid_promotion': True}
34
+ }
35
+
36
+
37
+ def test_build_disclosure_both_sources():
38
+ assert build_content_disclosure(True, True) == {
39
+ 'advertising_disclosure': {'is_paid_promotion': True},
40
+ 'ai_generated_disclosure': {
41
+ 'has_ai_generated_media': True,
42
+ 'ai_generated_detection_source': 'UserDeclared',
43
+ },
44
+ }
45
+ c2pa = build_content_disclosure(made_with_ai=True, ai_detection_source='C2paClient')
46
+ assert c2pa['ai_generated_disclosure']['ai_generated_detection_source'] == 'C2paClient'
47
+
48
+
49
+ def test_applied_disclosures():
50
+ assert applied_disclosures({}) == {'paid_partnership': False, 'made_with_ai': False}
51
+ assert applied_disclosures({
52
+ 'content_disclosure': {'ai_generated_disclosure': {'has_ai_generated_media': True}}
53
+ }) == {'paid_partnership': False, 'made_with_ai': True}
54
+
55
+
56
+ # ---------------------------------------------------------------- client
57
+
58
+
59
+ def test_cookie_jar_dedupes_by_name():
60
+ client = Client('en-US')
61
+ client.set_cookies({'auth_token': 'a', 'ct0': 'b', 'twid': 'u%3D1'})
62
+ client.set_cookies({'twid': 'u%3D2'})
63
+ cookies = client.get_cookies()
64
+ assert cookies['twid'] == 'u%3D2'
65
+ assert set(cookies) == {'auth_token', 'ct0', 'twid'}
66
+
67
+
68
+ def test_user_id_from_twid_cookie():
69
+ client = Client('en-US')
70
+ client.set_cookies({'twid': 'u%3D1938595739770470400'})
71
+ assert client._user_id_from_cookies() == '1938595739770470400'
72
+ client.set_cookies({'twid': 'not-a-user-id'})
73
+ assert client._user_id_from_cookies() is None
74
+
75
+
76
+ def test_identity_from_home_page():
77
+ client = Client('en-US')
78
+ html = (
79
+ '<html><script>{"users":{"entities":{"1938595739770470400":{'
80
+ '"name":"Tky","screen_name":"Tky567","id_str":"1938595739770470400",'
81
+ '"profile_image_url_https":"https://pbs.twimg.com/a_normal.png"}}}}</script></html>'
82
+ )
83
+ from bs4 import BeautifulSoup
84
+ client.client_transaction.home_page_response = BeautifulSoup(html, 'lxml')
85
+ client.set_cookies({'twid': 'u%3D1938595739770470400'})
86
+ identity = client.identity()
87
+ assert identity == {
88
+ 'user_id': '1938595739770470400',
89
+ 'name': 'Tky',
90
+ 'screen_name': 'Tky567',
91
+ 'avatar': 'https://pbs.twimg.com/a_normal.png',
92
+ }
93
+
94
+
95
+ def test_trimmed_user_payload_builds():
96
+ """The user object embedded in CreateTweet results has no entities block."""
97
+ from xselfbot.user import User
98
+ user = User(None, {
99
+ 'rest_id': '1',
100
+ 'core': {'name': 'Tky', 'screen_name': 'Tky567', 'created_at': 'x'},
101
+ 'avatar': {'image_url': 'https://pbs.twimg.com/b.png'},
102
+ })
103
+ assert user.screen_name == 'Tky567'
104
+ assert user.description_urls == []
105
+ assert user.followers_count == 0
106
+
107
+
108
+ # ---------------------------------------------------------------- create_tweet
109
+
110
+
111
+ class RecordingGQL(GQLClient):
112
+ """Real GQLClient with the transport replaced, so payload building is exercised."""
113
+
114
+ def __init__(self, base, response):
115
+ super().__init__(base)
116
+ self.calls = []
117
+ self.response = response
118
+
119
+ async def gql_post(self, url, variables, features=None, **kwargs):
120
+ self.calls.append({'url': url, 'variables': variables, 'features': features})
121
+ return self.response, None
122
+
123
+ async def gql_get(self, url, params=None, headers=None, **kwargs):
124
+ self.calls.append({'url': url, 'params': params})
125
+ return self.response, None
126
+
127
+
128
+ TWEET_RESPONSE = {
129
+ 'data': {
130
+ 'create_tweet': {
131
+ 'tweet_results': {
132
+ 'result': {
133
+ 'rest_id': '2121671135263920528',
134
+ 'legacy': {'full_text': 'hello'},
135
+ 'content_disclosure': {
136
+ 'ai_generated_disclosure': {
137
+ 'has_ai_generated_media': True,
138
+ 'ai_generated_detection_source': 'UserDeclared',
139
+ }
140
+ },
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+
148
+ def test_create_tweet_sends_disclosure():
149
+ client = Client('en-US')
150
+ client.gql = RecordingGQL(client, TWEET_RESPONSE)
151
+ tweet = asyncio.run(client.create_tweet('hello', made_with_ai=True))
152
+ call = client.gql.calls[0]
153
+ assert call['url'].endswith('/CreateTweet')
154
+ assert 'l4hk8uVGvR-a8kzQ4nOhPA' in call['url']
155
+ assert call['variables']['content_disclosure'] == {
156
+ 'ai_generated_disclosure': {
157
+ 'has_ai_generated_media': True,
158
+ 'ai_generated_detection_source': 'UserDeclared',
159
+ }
160
+ }
161
+ assert call['features']['content_disclosure_indicator_enabled'] is True
162
+ assert isinstance(tweet, Tweet)
163
+ assert tweet.id == '2121671135263920528'
164
+
165
+
166
+ def test_create_tweet_without_disclosure_omits_field():
167
+ client = Client('en-US')
168
+ client.gql = RecordingGQL(client, TWEET_RESPONSE)
169
+ asyncio.run(client.create_tweet('hello'))
170
+ assert 'content_disclosure' not in client.gql.calls[0]['variables']
171
+ assert client.gql.calls[0]['variables']['semantic_annotation_options'] == {
172
+ 'source': 'UniversalLink'
173
+ }
174
+
175
+
176
+ def test_add_and_delete_disclosure():
177
+ client = Client('en-US')
178
+ client.gql = RecordingGQL(client, {})
179
+ asyncio.run(client.add_content_disclosure('42', True, True))
180
+ assert client.gql.calls[0]['variables'] == {
181
+ 'tweet_id': '42',
182
+ 'advertising_disclosure': {'is_paid_promotion': True},
183
+ 'ai_generated_disclosure': {
184
+ 'has_ai_generated_media': True,
185
+ 'ai_generated_detection_source': 'UserDeclared',
186
+ },
187
+ }
188
+ asyncio.run(client.delete_content_disclosure('42'))
189
+ assert client.gql.calls[1]['variables'] == {'tweet_id': '42'}
190
+
191
+
192
+ def test_add_disclosure_requires_a_label():
193
+ client = Client('en-US')
194
+ client.gql = RecordingGQL(client, {})
195
+ try:
196
+ asyncio.run(client.add_content_disclosure('42'))
197
+ except ValueError as exc:
198
+ assert 'Nothing to disclose' in str(exc)
199
+ else:
200
+ raise AssertionError('expected ValueError')
201
+
202
+
203
+ # ---------------------------------------------------------------- transaction
204
+
205
+
206
+ def test_transaction_falls_back_without_bundle():
207
+ from xselfbot.x_client_transaction.transaction import ClientTransaction
208
+ tx = ClientTransaction()
209
+ tx.available = False
210
+ assert tx.generate_transaction_id('POST', '/i/api/graphql/x/CreateTweet') == '0' * 32
211
+
212
+
213
+ def test_transaction_keeps_real_path_when_available():
214
+ from xselfbot.x_client_transaction.transaction import ClientTransaction
215
+ tx = ClientTransaction()
216
+ assert tx.available is False # not initialised yet
217
+ assert tx.generate_transaction_id('GET', '/x') == '0' * 32
218
+
219
+
220
+ # ---------------------------------------------------------------- timeline cursors
221
+ #
222
+ # 2026-11: x.com moved the cursor value off `content.itemContent` and onto the holder
223
+ # itself. Both shapes are read; anything else is a crash on a healthy response.
224
+
225
+
226
+ def test_cursor_value_old_shape():
227
+ from xselfbot.utils import timeline_cursor_value
228
+ entry = {'entryId': 'cursor-bottom-1',
229
+ 'content': {'itemContent': {'value': 'OLD'}}}
230
+ assert timeline_cursor_value(entry) == 'OLD'
231
+
232
+
233
+ def test_cursor_value_current_shape():
234
+ from xselfbot.utils import timeline_cursor_value
235
+ entry = {'entryId': 'cursor-bottom-1',
236
+ 'content': {'__typename': 'TimelineTimelineCursor',
237
+ 'cursorType': 'Bottom', 'value': 'CUR'}}
238
+ assert timeline_cursor_value(entry) == 'CUR'
239
+
240
+
241
+ def test_cursor_value_module_item():
242
+ from xselfbot.utils import timeline_cursor_value
243
+ item = {'entryId': 'cursor-showmore-1', 'item': {'cursorType': 'ShowMore', 'value': 'S'}}
244
+ assert timeline_cursor_value(item) == 'S'
245
+ assert timeline_cursor_value({'entryId': 'x', 'content': {}}) is None
246
+ assert timeline_cursor_value(None) is None
247
+
248
+
249
+ TWEET_NODE = {
250
+ '__typename': 'Tweet',
251
+ 'rest_id': '123',
252
+ 'core': {'user_results': {'result': {
253
+ '__typename': 'User', 'rest_id': '9',
254
+ 'core': {'name': 'A', 'screen_name': 'a'}}}},
255
+ 'legacy': {'full_text': 'hi', 'created_at': 'x', 'lang': 'en',
256
+ 'is_quote_status': False},
257
+ }
258
+
259
+
260
+ def detail_response(cursor_content):
261
+ return {'data': {'threaded_conversation_with_injections_v2': {'instructions': [
262
+ {'type': 'TimelineClearCache'},
263
+ {'type': 'TimelineAddEntries', 'entries': [
264
+ {'entryId': 'tweet-123',
265
+ 'content': {'entryType': 'TimelineTimelineItem',
266
+ 'itemContent': {'itemType': 'TimelineTweet',
267
+ 'tweet_results': {'result': TWEET_NODE}}}},
268
+ {'entryId': 'cursor-bottom-1', 'content': cursor_content},
269
+ ]},
270
+ ]}}}
271
+
272
+
273
+ def test_get_tweet_by_id_reads_bottom_cursor():
274
+ client = Client('en-US')
275
+ client.gql = RecordingGQL(client, detail_response({
276
+ '__typename': 'TimelineTimelineCursor', 'cursorType': 'Bottom', 'value': 'NEXT',
277
+ }))
278
+ tweet = asyncio.run(client.get_tweet_by_id('123'))
279
+ assert tweet.id == '123'
280
+ assert tweet.replies.next_cursor == 'NEXT'
281
+ assert tweet.replies.next is not None
282
+
283
+
284
+ def test_get_tweet_by_id_reads_wrapped_cursor():
285
+ client = Client('en-US')
286
+ client.gql = RecordingGQL(client, detail_response({'itemContent': {'value': 'OLD'}}))
287
+ tweet = asyncio.run(client.get_tweet_by_id('123'))
288
+ assert tweet.replies.next_cursor == 'OLD'
289
+
290
+
291
+ # ---------------------------------------------------------------- read-side labels
292
+
293
+
294
+ def test_read_features_carry_disclosure_flags():
295
+ from xselfbot.constants import (
296
+ FEATURES, TWEET_RESULT_BY_REST_ID_FEATURES, TWEET_RESULTS_BY_REST_IDS_FEATURES
297
+ )
298
+ for features in (FEATURES, TWEET_RESULT_BY_REST_ID_FEATURES,
299
+ TWEET_RESULTS_BY_REST_IDS_FEATURES):
300
+ assert features['content_disclosure_indicator_enabled'] is True
301
+ assert features['content_disclosure_ai_generated_indicator_enabled'] is True
302
+
303
+
304
+ def test_applied_disclosures_accepts_a_tweet():
305
+ from xselfbot.content_disclosure import applied_disclosures
306
+
307
+ class Node:
308
+ _data = {'content_disclosure': {
309
+ 'ai_generated_disclosure': {'has_ai_generated_media': True}}}
310
+
311
+ assert applied_disclosures(Node()) == {'paid_partnership': False, 'made_with_ai': True}
312
+ assert applied_disclosures(None) == {'paid_partnership': False, 'made_with_ai': False}
313
+ assert applied_disclosures({'content_disclosure': {}}) == {
314
+ 'paid_partnership': False, 'made_with_ai': False}
315
+
316
+
317
+ # ---------------------------------------------------------------- degraded identity
318
+
319
+
320
+ def test_user_falls_back_marked_partial():
321
+ from xselfbot.errors import TwitterException
322
+
323
+ class RefusingClient(Client):
324
+ async def get_user_by_id(self, user_id):
325
+ raise TwitterException('403 from UserByRestId')
326
+
327
+ def identity(self):
328
+ return {'user_id': '1', 'screen_name': 'a', 'name': 'A',
329
+ 'avatar': 'https://pbs.twimg.com/a_normal.png'}
330
+
331
+ client = RefusingClient('en-US')
332
+ client.set_cookies({'twid': 'u%3D1'})
333
+ user = asyncio.run(client.user())
334
+ assert user.screen_name == 'a'
335
+ assert user.partial is True
336
+ assert user.followers_count == 0
337
+
338
+
339
+ def test_user_from_payload_is_not_partial():
340
+ from xselfbot.user import User
341
+ assert User(None, {'rest_id': '1', 'core': {'screen_name': 'a'}}).partial is False
342
+
343
+
344
+ def test_create_tweet_raises_when_result_is_not_a_tweet():
345
+ """A create call that returns a tombstone must not hand back None."""
346
+ from xselfbot.errors import CouldNotTweet
347
+
348
+ client = Client('en-US')
349
+ client.gql = RecordingGQL(client, {'data': {'create_tweet': {'tweet_results': {
350
+ 'result': {'__typename': 'TweetTombstone'}}}}})
351
+ try:
352
+ asyncio.run(client.create_tweet('hello'))
353
+ except CouldNotTweet as exc:
354
+ assert 'TweetTombstone' in str(exc)
355
+ else:
356
+ raise AssertionError('expected CouldNotTweet')