x-post-cli 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.
- x_post_cli-0.1.0/.claude/settings.local.json +9 -0
- x_post_cli-0.1.0/.gitignore +7 -0
- x_post_cli-0.1.0/PKG-INFO +6 -0
- x_post_cli-0.1.0/README.md +54 -0
- x_post_cli-0.1.0/pyproject.toml +24 -0
- x_post_cli-0.1.0/src/x_post/__init__.py +0 -0
- x_post_cli-0.1.0/src/x_post/auth.py +145 -0
- x_post_cli-0.1.0/src/x_post/cli.py +182 -0
- x_post_cli-0.1.0/src/x_post/client.py +146 -0
- x_post_cli-0.1.0/src/x_post/config.py +88 -0
- x_post_cli-0.1.0/tests/__init__.py +0 -0
- x_post_cli-0.1.0/tests/conftest.py +1 -0
- x_post_cli-0.1.0/tests/helpers.py +25 -0
- x_post_cli-0.1.0/tests/test_client.py +346 -0
- x_post_cli-0.1.0/tests/test_config.py +92 -0
- x_post_cli-0.1.0/uv.lock +224 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# x-post-cli
|
|
2
|
+
|
|
3
|
+
CLI utility for publishing tweets to Twitter/X via the official API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Run directly (no install needed)
|
|
9
|
+
uvx x-post-cli "Hello world!"
|
|
10
|
+
|
|
11
|
+
# Or install globally
|
|
12
|
+
uv tool install x-post-cli
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Prerequisites
|
|
16
|
+
|
|
17
|
+
1. Create a project and app at https://developer.x.com
|
|
18
|
+
2. In **User authentication settings** → Enable OAuth 2.0
|
|
19
|
+
3. Type of App: **Native App** (public client, PKCE)
|
|
20
|
+
4. Callback URL: `http://localhost:8000/callback`
|
|
21
|
+
5. Copy `Client ID` and `Client Secret`
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# First run — you'll be prompted for Client ID and Client Secret
|
|
27
|
+
x-post-cli "My first tweet via API!"
|
|
28
|
+
|
|
29
|
+
# From file
|
|
30
|
+
x-post-cli --from-file draft.txt
|
|
31
|
+
|
|
32
|
+
# Interactive input (Ctrl+D to send)
|
|
33
|
+
x-post-cli
|
|
34
|
+
|
|
35
|
+
# With an image (prompts for OAuth 1.0a keys on first use)
|
|
36
|
+
x-post-cli --image photo.jpg "Check this out!"
|
|
37
|
+
|
|
38
|
+
# Reply to a tweet (threading)
|
|
39
|
+
x-post-cli --reply-to 123456789 "Replying!"
|
|
40
|
+
|
|
41
|
+
# Re-authorize (clears OAuth 2.0 tokens)
|
|
42
|
+
x-post-cli --reset-auth "Hello again"
|
|
43
|
+
|
|
44
|
+
# Clear all credentials and start fresh
|
|
45
|
+
x-post-cli --reset-keys "Starting over"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Credentials are requested interactively on first run and saved to `~/.config/x-post/config.json`. On subsequent runs no prompts are needed. The config file can also be edited manually.
|
|
49
|
+
|
|
50
|
+
## Tests
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
uv run pytest
|
|
54
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "x-post-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.11"
|
|
5
|
+
dependencies = ["requests", "requests-oauthlib"]
|
|
6
|
+
|
|
7
|
+
[project.scripts]
|
|
8
|
+
x-post-cli = "x_post.cli:main"
|
|
9
|
+
|
|
10
|
+
[tool.pytest.ini_options]
|
|
11
|
+
testpaths = ["tests"]
|
|
12
|
+
pythonpath = ["tests"]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=9.0.2",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/x_post"]
|
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""OAuth 2.0 PKCE authentication flow for X."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import http.server
|
|
6
|
+
import secrets
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import webbrowser
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
_AUTH_URL = "https://twitter.com/i/oauth2/authorize"
|
|
15
|
+
_TOKEN_URL = "https://api.x.com/2/oauth2/token"
|
|
16
|
+
_USERINFO_URL = "https://api.x.com/2/users/me"
|
|
17
|
+
_REDIRECT_URI = "http://localhost:8000/callback"
|
|
18
|
+
_SCOPES = "tweet.write tweet.read users.read offline.access"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _generate_pkce() -> tuple[str, str]:
|
|
22
|
+
"""Generate PKCE code_verifier and code_challenge (S256)."""
|
|
23
|
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
|
|
24
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
25
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
26
|
+
return verifier, challenge
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_token_valid(token: str) -> bool:
|
|
30
|
+
"""Check whether *token* is still accepted by X API."""
|
|
31
|
+
resp = requests.get(
|
|
32
|
+
_USERINFO_URL,
|
|
33
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
34
|
+
timeout=10,
|
|
35
|
+
)
|
|
36
|
+
return resp.status_code == 200
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def refresh_access_token(
|
|
40
|
+
client_id: str, client_secret: str, refresh_token: str,
|
|
41
|
+
) -> tuple[str, str]:
|
|
42
|
+
"""Exchange a refresh token for new access + refresh tokens."""
|
|
43
|
+
resp = requests.post(
|
|
44
|
+
_TOKEN_URL,
|
|
45
|
+
auth=(client_id, client_secret),
|
|
46
|
+
data={
|
|
47
|
+
"grant_type": "refresh_token",
|
|
48
|
+
"refresh_token": refresh_token,
|
|
49
|
+
},
|
|
50
|
+
timeout=30,
|
|
51
|
+
)
|
|
52
|
+
resp.raise_for_status()
|
|
53
|
+
data = resp.json()
|
|
54
|
+
return data["access_token"], data["refresh_token"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def authenticate(client_id: str, client_secret: str) -> tuple[str, str]:
|
|
58
|
+
"""Run the full OAuth 2.0 PKCE browser flow.
|
|
59
|
+
|
|
60
|
+
Opens the default browser for user consent, captures the redirect
|
|
61
|
+
on a local server, and exchanges the code for tokens.
|
|
62
|
+
|
|
63
|
+
Returns (access_token, refresh_token).
|
|
64
|
+
"""
|
|
65
|
+
code_verifier, code_challenge = _generate_pkce()
|
|
66
|
+
state = secrets.token_urlsafe(16)
|
|
67
|
+
|
|
68
|
+
auth_code: str | None = None
|
|
69
|
+
error: str | None = None
|
|
70
|
+
server_ready = threading.Event()
|
|
71
|
+
|
|
72
|
+
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
73
|
+
def do_GET(self) -> None: # noqa: N802
|
|
74
|
+
nonlocal auth_code, error
|
|
75
|
+
params = urllib.parse.parse_qs(
|
|
76
|
+
urllib.parse.urlparse(self.path).query,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
received_state = params.get("state", [None])[0]
|
|
80
|
+
if received_state != state:
|
|
81
|
+
error = "state_mismatch"
|
|
82
|
+
self._respond("Authorization failed: state mismatch.")
|
|
83
|
+
elif "code" in params:
|
|
84
|
+
auth_code = params["code"][0]
|
|
85
|
+
self._respond("Authorization successful! You can close this tab.")
|
|
86
|
+
else:
|
|
87
|
+
error = params.get("error", ["unknown"])[0]
|
|
88
|
+
self._respond(f"Authorization failed: {error}")
|
|
89
|
+
|
|
90
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
91
|
+
|
|
92
|
+
def _respond(self, message: str) -> None:
|
|
93
|
+
self.send_response(200)
|
|
94
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
95
|
+
self.end_headers()
|
|
96
|
+
self.wfile.write(
|
|
97
|
+
f"<html><body><h2>{message}</h2></body></html>".encode(),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
server = http.server.HTTPServer(("localhost", 8000), _CallbackHandler)
|
|
104
|
+
|
|
105
|
+
def _serve() -> None:
|
|
106
|
+
server_ready.set()
|
|
107
|
+
server.serve_forever()
|
|
108
|
+
|
|
109
|
+
thread = threading.Thread(target=_serve, daemon=True)
|
|
110
|
+
thread.start()
|
|
111
|
+
server_ready.wait()
|
|
112
|
+
|
|
113
|
+
params = urllib.parse.urlencode({
|
|
114
|
+
"response_type": "code",
|
|
115
|
+
"client_id": client_id,
|
|
116
|
+
"redirect_uri": _REDIRECT_URI,
|
|
117
|
+
"scope": _SCOPES,
|
|
118
|
+
"state": state,
|
|
119
|
+
"code_challenge": code_challenge,
|
|
120
|
+
"code_challenge_method": "S256",
|
|
121
|
+
})
|
|
122
|
+
authorization_url = f"{_AUTH_URL}?{params}"
|
|
123
|
+
print(f"Opening browser for authorization...\n{authorization_url}")
|
|
124
|
+
webbrowser.open(authorization_url)
|
|
125
|
+
|
|
126
|
+
thread.join()
|
|
127
|
+
|
|
128
|
+
if error or auth_code is None:
|
|
129
|
+
print(f"Authorization failed: {error}", file=sys.stderr)
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
|
|
132
|
+
token_resp = requests.post(
|
|
133
|
+
_TOKEN_URL,
|
|
134
|
+
auth=(client_id, client_secret),
|
|
135
|
+
data={
|
|
136
|
+
"grant_type": "authorization_code",
|
|
137
|
+
"code": auth_code,
|
|
138
|
+
"redirect_uri": _REDIRECT_URI,
|
|
139
|
+
"code_verifier": code_verifier,
|
|
140
|
+
},
|
|
141
|
+
timeout=30,
|
|
142
|
+
)
|
|
143
|
+
token_resp.raise_for_status()
|
|
144
|
+
data = token_resp.json()
|
|
145
|
+
return data["access_token"], data["refresh_token"]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""CLI entry-point for x-post."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from x_post.auth import authenticate, is_token_valid, refresh_access_token
|
|
8
|
+
from x_post.client import OAuth1Credentials, XClient
|
|
9
|
+
from x_post.config import ConfigStore, JsonConfigStore, prompt_if_missing
|
|
10
|
+
|
|
11
|
+
_MAX_LENGTH = 280
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
description="Publish a tweet on Twitter/X.",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument("text", nargs="?", help="Tweet text (inline)")
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--from-file", type=pathlib.Path, help="Read tweet text from a file",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--reply-to",
|
|
24
|
+
type=str,
|
|
25
|
+
metavar="TWEET_ID",
|
|
26
|
+
help="Tweet ID to reply to (for threading)",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--image",
|
|
30
|
+
type=pathlib.Path,
|
|
31
|
+
metavar="PATH",
|
|
32
|
+
help="Attach an image (jpg/png/gif/webp, max 5 MB)",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--reset-auth",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="Clear saved OAuth 2.0 tokens and re-authorize",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--reset-keys",
|
|
41
|
+
action="store_true",
|
|
42
|
+
help="Clear all saved credentials and re-prompt from scratch",
|
|
43
|
+
)
|
|
44
|
+
return parser.parse_args(argv)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_post_text(args: argparse.Namespace) -> str:
|
|
48
|
+
if args.text:
|
|
49
|
+
return args.text
|
|
50
|
+
if args.from_file:
|
|
51
|
+
return args.from_file.read_text(encoding="utf-8").strip()
|
|
52
|
+
print("Enter tweet text (Ctrl+D to send):")
|
|
53
|
+
return sys.stdin.read().strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ensure_token(
|
|
57
|
+
config: ConfigStore,
|
|
58
|
+
client_id: str,
|
|
59
|
+
client_secret: str,
|
|
60
|
+
*,
|
|
61
|
+
force: bool,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Return a valid access token, running OAuth if needed."""
|
|
64
|
+
access_token = config.get("access_token")
|
|
65
|
+
refresh_token = config.get("refresh_token")
|
|
66
|
+
|
|
67
|
+
if not force and access_token and is_token_valid(access_token):
|
|
68
|
+
return access_token
|
|
69
|
+
|
|
70
|
+
if not force and refresh_token:
|
|
71
|
+
try:
|
|
72
|
+
new_access, new_refresh = refresh_access_token(
|
|
73
|
+
client_id, client_secret, refresh_token,
|
|
74
|
+
)
|
|
75
|
+
config.set_many({
|
|
76
|
+
"access_token": new_access,
|
|
77
|
+
"refresh_token": new_refresh,
|
|
78
|
+
})
|
|
79
|
+
return new_access
|
|
80
|
+
except Exception:
|
|
81
|
+
pass # fall through to full auth
|
|
82
|
+
|
|
83
|
+
new_access, new_refresh = authenticate(client_id, client_secret)
|
|
84
|
+
config.set_many({"access_token": new_access, "refresh_token": new_refresh})
|
|
85
|
+
return new_access
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _ensure_oauth1(config: ConfigStore) -> OAuth1Credentials:
|
|
89
|
+
"""Return OAuth 1.0a credentials, prompting for any missing keys."""
|
|
90
|
+
if not config.get("api_key"):
|
|
91
|
+
print(
|
|
92
|
+
"\n"
|
|
93
|
+
"Image upload requires OAuth 1.0a credentials\n"
|
|
94
|
+
"=============================================\n"
|
|
95
|
+
"In your app at https://developer.x.com:\n"
|
|
96
|
+
"\n"
|
|
97
|
+
"1. Go to Keys and Tokens tab\n"
|
|
98
|
+
"2. Under Consumer Keys, copy API Key and API Key Secret\n"
|
|
99
|
+
"3. Under Authentication Tokens, generate Access Token and Secret\n"
|
|
100
|
+
" (make sure the token has Read and Write permissions)\n",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return OAuth1Credentials(
|
|
104
|
+
api_key=prompt_if_missing(config, "api_key", "API Key"),
|
|
105
|
+
api_key_secret=prompt_if_missing(config, "api_key_secret", "API Key Secret"),
|
|
106
|
+
access_token=prompt_if_missing(config, "oauth1_access_token", "OAuth 1.0a Access Token"),
|
|
107
|
+
access_token_secret=prompt_if_missing(
|
|
108
|
+
config, "oauth1_access_token_secret", "OAuth 1.0a Access Token Secret",
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main(argv: list[str] | None = None, *, _config: ConfigStore | None = None) -> None:
|
|
114
|
+
args = _parse_args(argv)
|
|
115
|
+
config = _config or JsonConfigStore()
|
|
116
|
+
|
|
117
|
+
if args.reset_keys:
|
|
118
|
+
config.remove([
|
|
119
|
+
"client_id", "client_secret",
|
|
120
|
+
"access_token", "refresh_token",
|
|
121
|
+
"api_key", "api_key_secret",
|
|
122
|
+
"oauth1_access_token", "oauth1_access_token_secret",
|
|
123
|
+
])
|
|
124
|
+
elif args.reset_auth:
|
|
125
|
+
config.remove(["access_token", "refresh_token"])
|
|
126
|
+
|
|
127
|
+
if not config.get("client_id"):
|
|
128
|
+
print(
|
|
129
|
+
"\n"
|
|
130
|
+
"First-time setup\n"
|
|
131
|
+
"================\n"
|
|
132
|
+
"You need OAuth 2.0 credentials from the X Developer Portal.\n"
|
|
133
|
+
"\n"
|
|
134
|
+
"1. Go to https://developer.x.com and create a project & app\n"
|
|
135
|
+
"2. In User authentication settings, enable OAuth 2.0\n"
|
|
136
|
+
"3. Set type to Native App, callback URL: http://localhost:8000/callback\n"
|
|
137
|
+
"4. Copy the Client ID and Client Secret below\n",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
client_id = prompt_if_missing(config, "client_id", "Client ID")
|
|
141
|
+
client_secret = prompt_if_missing(config, "client_secret", "Client Secret")
|
|
142
|
+
|
|
143
|
+
text = _read_post_text(args)
|
|
144
|
+
if not text:
|
|
145
|
+
print("Empty tweet text, aborting.", file=sys.stderr)
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
if len(text) > _MAX_LENGTH:
|
|
148
|
+
print(
|
|
149
|
+
f"Tweet too long: {len(text)}/{_MAX_LENGTH} characters.",
|
|
150
|
+
file=sys.stderr,
|
|
151
|
+
)
|
|
152
|
+
sys.exit(1)
|
|
153
|
+
|
|
154
|
+
access_token = _ensure_token(
|
|
155
|
+
config, client_id, client_secret,
|
|
156
|
+
force=args.reset_auth,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
oauth1: OAuth1Credentials | None = None
|
|
160
|
+
if args.image:
|
|
161
|
+
oauth1 = _ensure_oauth1(config)
|
|
162
|
+
|
|
163
|
+
client = XClient(access_token, oauth1=oauth1)
|
|
164
|
+
|
|
165
|
+
media_ids: list[str] | None = None
|
|
166
|
+
if args.image:
|
|
167
|
+
if not args.image.exists():
|
|
168
|
+
print(f"Image not found: {args.image}", file=sys.stderr)
|
|
169
|
+
sys.exit(1)
|
|
170
|
+
try:
|
|
171
|
+
media_id = client.upload_media(args.image)
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
print(str(exc), file=sys.stderr)
|
|
174
|
+
sys.exit(1)
|
|
175
|
+
media_ids = [media_id]
|
|
176
|
+
|
|
177
|
+
result = client.create_tweet(
|
|
178
|
+
text, reply_to_tweet_id=args.reply_to, media_ids=media_ids,
|
|
179
|
+
)
|
|
180
|
+
print(f"Tweet published!\n{result.url}")
|
|
181
|
+
print(f"\nTo continue this thread:\n"
|
|
182
|
+
f"x-post-cli --reply-to {result.tweet_id} \"Next tweet text\"")
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""X API client for creating posts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pathlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from requests_oauthlib import OAuth1
|
|
11
|
+
|
|
12
|
+
_BASE_URL = "https://api.x.com/2"
|
|
13
|
+
_UPLOAD_URL = "https://upload.twitter.com/1.1/media/upload.json"
|
|
14
|
+
_SUPPORTED_IMAGE_TYPES = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
|
15
|
+
_MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5 MB
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class OAuth1Credentials:
|
|
20
|
+
"""OAuth 1.0a credentials required for media uploads."""
|
|
21
|
+
|
|
22
|
+
api_key: str
|
|
23
|
+
api_key_secret: str
|
|
24
|
+
access_token: str
|
|
25
|
+
access_token_secret: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class TweetResult:
|
|
30
|
+
"""Result of publishing a tweet."""
|
|
31
|
+
|
|
32
|
+
tweet_id: str
|
|
33
|
+
url: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class XAPI(Protocol):
|
|
37
|
+
"""Interface for X API operations."""
|
|
38
|
+
|
|
39
|
+
def get_username(self) -> str:
|
|
40
|
+
"""Return the authenticated user's username."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def create_tweet(
|
|
44
|
+
self,
|
|
45
|
+
text: str,
|
|
46
|
+
*,
|
|
47
|
+
reply_to_tweet_id: str | None = None,
|
|
48
|
+
media_ids: list[str] | None = None,
|
|
49
|
+
) -> TweetResult:
|
|
50
|
+
"""Publish a tweet and return the result with ID and URL."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class XClient:
|
|
55
|
+
"""HTTP client for X REST API v2.
|
|
56
|
+
|
|
57
|
+
Usage::
|
|
58
|
+
|
|
59
|
+
client = XClient(access_token="...")
|
|
60
|
+
url = client.create_tweet("Hello X!")
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
access_token: str,
|
|
66
|
+
*,
|
|
67
|
+
oauth1: OAuth1Credentials | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._session = requests.Session()
|
|
70
|
+
self._session.headers.update({
|
|
71
|
+
"Authorization": f"Bearer {access_token}",
|
|
72
|
+
})
|
|
73
|
+
self._oauth1 = oauth1
|
|
74
|
+
self._username: str | None = None
|
|
75
|
+
|
|
76
|
+
def get_username(self) -> str:
|
|
77
|
+
"""Return the authenticated user's @username (cached)."""
|
|
78
|
+
if self._username is None:
|
|
79
|
+
resp = self._session.get(f"{_BASE_URL}/users/me")
|
|
80
|
+
resp.raise_for_status()
|
|
81
|
+
self._username = resp.json()["data"]["username"]
|
|
82
|
+
return self._username
|
|
83
|
+
|
|
84
|
+
def upload_media(self, path: pathlib.Path) -> str:
|
|
85
|
+
"""Upload an image file and return the media_id string.
|
|
86
|
+
|
|
87
|
+
Requires OAuth 1.0a credentials (passed via ``oauth1`` at construction).
|
|
88
|
+
Supports jpg, png, gif, webp up to 5 MB.
|
|
89
|
+
Raises ``ValueError`` for unsupported format, oversized files,
|
|
90
|
+
or missing OAuth 1.0a credentials.
|
|
91
|
+
"""
|
|
92
|
+
if self._oauth1 is None:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"OAuth 1.0a credentials are required for media uploads.",
|
|
95
|
+
)
|
|
96
|
+
suffix = path.suffix.lower()
|
|
97
|
+
if suffix not in _SUPPORTED_IMAGE_TYPES:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Unsupported image format '{suffix}'. "
|
|
100
|
+
f"Supported: {', '.join(sorted(_SUPPORTED_IMAGE_TYPES))}",
|
|
101
|
+
)
|
|
102
|
+
size = path.stat().st_size
|
|
103
|
+
if size > _MAX_IMAGE_SIZE:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"Image too large ({size / 1024 / 1024:.1f} MB). "
|
|
106
|
+
f"Maximum: {_MAX_IMAGE_SIZE / 1024 / 1024:.0f} MB",
|
|
107
|
+
)
|
|
108
|
+
auth = OAuth1(
|
|
109
|
+
self._oauth1.api_key,
|
|
110
|
+
self._oauth1.api_key_secret,
|
|
111
|
+
self._oauth1.access_token,
|
|
112
|
+
self._oauth1.access_token_secret,
|
|
113
|
+
)
|
|
114
|
+
with open(path, "rb") as f:
|
|
115
|
+
resp = requests.post(_UPLOAD_URL, files={"media": f}, auth=auth)
|
|
116
|
+
if not resp.ok:
|
|
117
|
+
raise requests.HTTPError(
|
|
118
|
+
f"{resp.status_code}: {resp.text}", response=resp,
|
|
119
|
+
)
|
|
120
|
+
return str(resp.json()["media_id"])
|
|
121
|
+
|
|
122
|
+
def create_tweet(
|
|
123
|
+
self,
|
|
124
|
+
text: str,
|
|
125
|
+
*,
|
|
126
|
+
reply_to_tweet_id: str | None = None,
|
|
127
|
+
media_ids: list[str] | None = None,
|
|
128
|
+
) -> TweetResult:
|
|
129
|
+
"""Publish a tweet, optionally with media or as a reply."""
|
|
130
|
+
body: dict = {"text": text}
|
|
131
|
+
if reply_to_tweet_id is not None:
|
|
132
|
+
body["reply"] = {"in_reply_to_tweet_id": reply_to_tweet_id}
|
|
133
|
+
if media_ids is not None:
|
|
134
|
+
body["media"] = {"media_ids": media_ids}
|
|
135
|
+
|
|
136
|
+
resp = self._session.post(f"{_BASE_URL}/tweets", json=body)
|
|
137
|
+
if not resp.ok:
|
|
138
|
+
raise requests.HTTPError(
|
|
139
|
+
f"{resp.status_code}: {resp.text}", response=resp,
|
|
140
|
+
)
|
|
141
|
+
tweet_id = resp.json()["data"]["id"]
|
|
142
|
+
username = self.get_username()
|
|
143
|
+
return TweetResult(
|
|
144
|
+
tweet_id=tweet_id,
|
|
145
|
+
url=f"https://x.com/{username}/status/{tweet_id}",
|
|
146
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Persistent JSON config store for credentials."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Callable, Protocol
|
|
10
|
+
|
|
11
|
+
_DEFAULT_PATH = pathlib.Path.home() / ".config" / "x-post" / "config.json"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigStore(Protocol):
|
|
15
|
+
"""Read/write access to persistent key-value config."""
|
|
16
|
+
|
|
17
|
+
def get(self, key: str) -> str | None: ...
|
|
18
|
+
def set(self, key: str, value: str) -> None: ...
|
|
19
|
+
def set_many(self, items: dict[str, str]) -> None: ...
|
|
20
|
+
def remove(self, keys: list[str]) -> None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JsonConfigStore:
|
|
24
|
+
"""Config store backed by a JSON file (default ``~/.config/x-post/config.json``).
|
|
25
|
+
|
|
26
|
+
Creates the directory and file on first write. Sets ``0o600`` permissions
|
|
27
|
+
after each write to protect secrets.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, path: pathlib.Path = _DEFAULT_PATH) -> None:
|
|
31
|
+
self._path = path
|
|
32
|
+
|
|
33
|
+
def get(self, key: str) -> str | None:
|
|
34
|
+
data = self._read()
|
|
35
|
+
return data.get(key)
|
|
36
|
+
|
|
37
|
+
def set(self, key: str, value: str) -> None:
|
|
38
|
+
data = self._read()
|
|
39
|
+
data[key] = value
|
|
40
|
+
self._write(data)
|
|
41
|
+
|
|
42
|
+
def set_many(self, items: dict[str, str]) -> None:
|
|
43
|
+
data = self._read()
|
|
44
|
+
data.update(items)
|
|
45
|
+
self._write(data)
|
|
46
|
+
|
|
47
|
+
def remove(self, keys: list[str]) -> None:
|
|
48
|
+
data = self._read()
|
|
49
|
+
for k in keys:
|
|
50
|
+
data.pop(k, None)
|
|
51
|
+
self._write(data)
|
|
52
|
+
|
|
53
|
+
def _read(self) -> dict[str, str]:
|
|
54
|
+
if not self._path.exists():
|
|
55
|
+
return {}
|
|
56
|
+
return json.loads(self._path.read_text(encoding="utf-8"))
|
|
57
|
+
|
|
58
|
+
def _write(self, data: dict[str, str]) -> None:
|
|
59
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self._path.write_text(
|
|
61
|
+
json.dumps(data, indent=2) + "\n", encoding="utf-8",
|
|
62
|
+
)
|
|
63
|
+
os.chmod(self._path, 0o600)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def prompt_if_missing(
|
|
67
|
+
config: ConfigStore,
|
|
68
|
+
key: str,
|
|
69
|
+
display_name: str,
|
|
70
|
+
*,
|
|
71
|
+
prompt_fn: Callable[[str], str] | None = None,
|
|
72
|
+
) -> str:
|
|
73
|
+
"""Return the value for *key*, prompting the user if it's not stored yet.
|
|
74
|
+
|
|
75
|
+
Saves the prompted value into *config* for next time.
|
|
76
|
+
Exits if the user provides an empty value.
|
|
77
|
+
"""
|
|
78
|
+
value = config.get(key)
|
|
79
|
+
if value:
|
|
80
|
+
return value
|
|
81
|
+
_prompt = prompt_fn or input
|
|
82
|
+
value = _prompt(f"{display_name}: ")
|
|
83
|
+
if not value or not value.strip():
|
|
84
|
+
print("Value required. Aborting.", file=sys.stderr)
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
value = value.strip()
|
|
87
|
+
config.set(key, value)
|
|
88
|
+
return value
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared test fixtures."""
|