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.
- xselfbot-4.0.0/LICENSE +21 -0
- xselfbot-4.0.0/PKG-INFO +138 -0
- xselfbot-4.0.0/README.md +112 -0
- xselfbot-4.0.0/setup.cfg +4 -0
- xselfbot-4.0.0/setup.py +33 -0
- xselfbot-4.0.0/tests/test_fork.py +356 -0
- xselfbot-4.0.0/xselfbot/__init__.py +34 -0
- xselfbot-4.0.0/xselfbot/_captcha/__init__.py +2 -0
- xselfbot-4.0.0/xselfbot/_captcha/base.py +111 -0
- xselfbot-4.0.0/xselfbot/_captcha/capsolver.py +95 -0
- xselfbot-4.0.0/xselfbot/bookmark.py +64 -0
- xselfbot-4.0.0/xselfbot/client/client.py +4527 -0
- xselfbot-4.0.0/xselfbot/client/gql.py +730 -0
- xselfbot-4.0.0/xselfbot/client/v11.py +512 -0
- xselfbot-4.0.0/xselfbot/community.py +282 -0
- xselfbot-4.0.0/xselfbot/constants.py +268 -0
- xselfbot-4.0.0/xselfbot/content_disclosure.py +104 -0
- xselfbot-4.0.0/xselfbot/errors.py +110 -0
- xselfbot-4.0.0/xselfbot/geo.py +82 -0
- xselfbot-4.0.0/xselfbot/group.py +259 -0
- xselfbot-4.0.0/xselfbot/guest/__init__.py +3 -0
- xselfbot-4.0.0/xselfbot/guest/client.py +411 -0
- xselfbot-4.0.0/xselfbot/guest/tweet.py +235 -0
- xselfbot-4.0.0/xselfbot/guest/user.py +196 -0
- xselfbot-4.0.0/xselfbot/list.py +255 -0
- xselfbot-4.0.0/xselfbot/media.py +346 -0
- xselfbot-4.0.0/xselfbot/message.py +143 -0
- xselfbot-4.0.0/xselfbot/notification.py +47 -0
- xselfbot-4.0.0/xselfbot/py.typed +0 -0
- xselfbot-4.0.0/xselfbot/streaming.py +266 -0
- xselfbot-4.0.0/xselfbot/trend.py +93 -0
- xselfbot-4.0.0/xselfbot/tweet.py +790 -0
- xselfbot-4.0.0/xselfbot/ui_metrics/__init__.py +25 -0
- xselfbot-4.0.0/xselfbot/ui_metrics/dom.py +52 -0
- xselfbot-4.0.0/xselfbot/user.py +532 -0
- xselfbot-4.0.0/xselfbot/utils.py +427 -0
- xselfbot-4.0.0/xselfbot/x_client_transaction/__init__.py +8 -0
- xselfbot-4.0.0/xselfbot/x_client_transaction/cubic_curve.py +48 -0
- xselfbot-4.0.0/xselfbot/x_client_transaction/interpolate.py +23 -0
- xselfbot-4.0.0/xselfbot/x_client_transaction/rotation.py +27 -0
- xselfbot-4.0.0/xselfbot/x_client_transaction/transaction.py +170 -0
- xselfbot-4.0.0/xselfbot/x_client_transaction/utils.py +84 -0
- xselfbot-4.0.0/xselfbot.egg-info/PKG-INFO +138 -0
- xselfbot-4.0.0/xselfbot.egg-info/SOURCES.txt +45 -0
- xselfbot-4.0.0/xselfbot.egg-info/dependency_links.txt +1 -0
- xselfbot-4.0.0/xselfbot.egg-info/requires.txt +8 -0
- 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.
|
xselfbot-4.0.0/PKG-INFO
ADDED
|
@@ -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).
|
xselfbot-4.0.0/README.md
ADDED
|
@@ -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).
|
xselfbot-4.0.0/setup.cfg
ADDED
xselfbot-4.0.0/setup.py
ADDED
|
@@ -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')
|