taibai 0.2.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.
taibai-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.3
2
+ Name: taibai
3
+ Version: 0.2.0
4
+ Summary: Fediverse CLI — celestial messenger for the federation.
5
+ Author: marvin8
6
+ Author-email: marvin8 <marvin8@tuta.io>
7
+ License: AGPL-3.0-or-later
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: End Users/Desktop
11
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Communications
19
+ Classifier: Topic :: Internet
20
+ Requires-Dist: cyclopts~=4.10.2
21
+ Requires-Dist: httpx[http2,zstd]~=0.28.1
22
+ Requires-Dist: keyring~=25.7.0
23
+ Requires-Dist: longwei~=1.4.0
24
+ Requires-Dist: platformdirs~=4.9.6
25
+ Requires-Dist: rich~=15.0.0
26
+ Requires-Python: >=3.11, <3.15
27
+ Project-URL: Source, https://codeberg.org/MarvinsMastodonTools/taibai
28
+ Project-URL: Issues, https://codeberg.org/MarvinsMastodonTools/taibai/issues
29
+ Description-Content-Type: text/markdown
30
+
31
+ # taibai
32
+
33
+ Fediverse CLI tool — celestial messenger for the federation.
34
+
35
+ **Taibai** (太白金星, Tàibái Jīnxīng — "Gold Star of Venus") is the celestial herald in Chinese
36
+ mythology, tasked with carrying messages between heaven and earth. The name fits a tool that sends
37
+ and receives communications across a federated network, and continues the Chinese mythology theme
38
+ of its companion library [longwei](https://codeberg.org/MarvinsMastodonTools/longwei) (龙威).
39
+
40
+ Built on [longwei](https://codeberg.org/MarvinsMastodonTools/longwei).
41
+
42
+ ## Install
43
+
44
+ ```sh
45
+ uv tool install taibai
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ```sh
51
+ taibai init # authenticate with a Fediverse instance
52
+ taibai whoami # display current account
53
+ taibai post "Hello, world!" # publish a status
54
+ taibai notifications # show new notifications
55
+ ```
56
+
57
+ Use `--profile` / `-p` (or `TAIBAI_PROFILE` env var) to manage multiple accounts.
58
+
59
+ ## License
60
+
61
+ [AGPL-3.0-or-later](LICENSE.md)
taibai-0.2.0/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # taibai
2
+
3
+ Fediverse CLI tool — celestial messenger for the federation.
4
+
5
+ **Taibai** (太白金星, Tàibái Jīnxīng — "Gold Star of Venus") is the celestial herald in Chinese
6
+ mythology, tasked with carrying messages between heaven and earth. The name fits a tool that sends
7
+ and receives communications across a federated network, and continues the Chinese mythology theme
8
+ of its companion library [longwei](https://codeberg.org/MarvinsMastodonTools/longwei) (龙威).
9
+
10
+ Built on [longwei](https://codeberg.org/MarvinsMastodonTools/longwei).
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ uv tool install taibai
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```sh
21
+ taibai init # authenticate with a Fediverse instance
22
+ taibai whoami # display current account
23
+ taibai post "Hello, world!" # publish a status
24
+ taibai notifications # show new notifications
25
+ ```
26
+
27
+ Use `--profile` / `-p` (or `TAIBAI_PROFILE` env var) to manage multiple accounts.
28
+
29
+ ## License
30
+
31
+ [AGPL-3.0-or-later](LICENSE.md)
@@ -0,0 +1,83 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.11.3,<0.12.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "taibai"
7
+ version = "0.2.0"
8
+ description = "Fediverse CLI — celestial messenger for the federation."
9
+ readme = "README.md"
10
+ license = { text = "AGPL-3.0-or-later" }
11
+ authors = [
12
+ { name = "marvin8", email = "marvin8@tuta.io" },
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Topic :: Communications",
26
+ "Topic :: Internet",
27
+ ]
28
+ requires-python = ">=3.11, <3.15"
29
+ dependencies = [
30
+ "cyclopts~=4.10.2",
31
+ "httpx[http2,zstd]~=0.28.1",
32
+ "keyring~=25.7.0",
33
+ "longwei~=1.4.0",
34
+ "platformdirs~=4.9.6",
35
+ "rich~=15.0.0",
36
+ ]
37
+
38
+ [project.scripts]
39
+ taibai = "taibai.cli:app"
40
+
41
+ [project.urls]
42
+ Source = "https://codeberg.org/MarvinsMastodonTools/taibai"
43
+ Issues = "https://codeberg.org/MarvinsMastodonTools/taibai/issues"
44
+
45
+ [dependency-groups]
46
+ dev = [
47
+ "bump-my-version~=1.3.0",
48
+ "complexipy~=5.3.0",
49
+ "git-cliff~=2.12.0",
50
+ "nox-uv~=0.7.1",
51
+ "prek~=0.3.9",
52
+ "pytest~=9.0.3",
53
+ "pytest-asyncio~=1.3.0",
54
+ "pytest-cov~=7.1.0",
55
+ "pytest-httpx~=0.36.2",
56
+ "ruff~=0.15.11",
57
+ "ty~=0.0.31",
58
+ "uv~=0.11.7",
59
+ ]
60
+ docs = [
61
+ "mkdocs~=1.6.1",
62
+ "mkdocs-material~=9.7.6",
63
+ "mkdocstrings~=1.0.4",
64
+ "mkdocstrings-python~=2.0.3",
65
+ "mike~=2.2.0",
66
+ ]
67
+ [tool.bumpversion]
68
+ commit = true
69
+ tag = true
70
+ tag_name = "{new_version}"
71
+ parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
72
+ serialize = ["{major}.{minor}.{patch}"]
73
+ message = "bump: version {current_version} → {new_version}"
74
+ pre_commit_hooks = ["uv sync", "git add uv.lock"]
75
+
76
+ [[tool.bumpversion.files]]
77
+ filename = "pyproject.toml"
78
+ search = 'version = "{current_version}"'
79
+ replace = 'version = "{new_version}"'
80
+
81
+ [tool.pytest.ini_options]
82
+ asyncio_default_fixture_loop_scope = "session"
83
+ asyncio_mode = "auto"
@@ -0,0 +1 @@
1
+ """taibai — Fediverse CLI tool."""
@@ -0,0 +1,20 @@
1
+ """Shared helpers for taibai commands."""
2
+
3
+ import asyncio
4
+ from collections.abc import Coroutine
5
+ from typing import Any
6
+
7
+ from rich.console import Console
8
+
9
+ err_console = Console(stderr=True)
10
+
11
+
12
+ def run_async(coro: Coroutine[Any, Any, Any]) -> Any:
13
+ """Run an async coroutine from a synchronous command."""
14
+ return asyncio.run(coro)
15
+
16
+
17
+ def die(message: str, exit_code: int = 1) -> None:
18
+ """Print an error to stderr and exit."""
19
+ err_console.print(f"[bold red]Error:[/bold red] {message}")
20
+ raise SystemExit(exit_code)
@@ -0,0 +1,18 @@
1
+ """Taibai CLI entry point."""
2
+
3
+ from cyclopts import App
4
+
5
+ from taibai.commands.init import init_command
6
+ from taibai.commands.notifications import notifications_command
7
+ from taibai.commands.post import post_command
8
+ from taibai.commands.whoami import whoami_command
9
+
10
+ app = App(
11
+ name="taibai",
12
+ help="Fediverse CLI — celestial messenger for the federation.",
13
+ )
14
+
15
+ app.command(name="init")(init_command)
16
+ app.command(name="post")(post_command)
17
+ app.command(name="notifications")(notifications_command)
18
+ app.command(name="whoami")(whoami_command)
@@ -0,0 +1 @@
1
+ """Taibai CLI commands."""
@@ -0,0 +1,111 @@
1
+ """The `init` command: register an app and complete the OAuth flow."""
2
+
3
+ import sys
4
+ from importlib.metadata import version
5
+ from typing import Annotated
6
+
7
+ import httpx
8
+ from cyclopts import Parameter
9
+ from longwei import ActivityPubError
10
+ from longwei import APClient
11
+ from longwei import NetworkError
12
+ from longwei import UnauthorizedError
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.prompt import Confirm
16
+ from rich.prompt import Prompt
17
+
18
+ from taibai._utils import die
19
+ from taibai._utils import run_async
20
+ from taibai.config import Profile
21
+ from taibai.config import get_profile
22
+ from taibai.config import save_profile
23
+ from taibai.credentials import save_token
24
+
25
+ console = Console()
26
+
27
+ _CLIENT_WEBSITE = "https://codeberg.org/MarvinsMastodonTools/taibai"
28
+ _USER_AGENT = f"taibai_v{version('taibai')}_Python_{sys.version.split()[0]}"
29
+
30
+
31
+ def init_command(
32
+ instance: Annotated[str | None, Parameter(name=["--instance", "-i"], help="Fediverse instance URL.")] = None,
33
+ profile: Annotated[
34
+ str, Parameter(name=["--profile", "-p"], help="Profile name.", env_var="TAIBAI_PROFILE")
35
+ ] = "default",
36
+ ) -> None:
37
+ """Register an app with a Fediverse instance and store credentials locally."""
38
+ if instance is None:
39
+ instance = Prompt.ask("Instance URL (e.g. https://mastodon.social)")
40
+
41
+ instance = instance.rstrip("/")
42
+ if "://" not in instance:
43
+ instance = f"https://{instance}"
44
+
45
+ existing = get_profile(profile)
46
+ if existing is not None:
47
+ confirmed = Confirm.ask(
48
+ f"Profile '{profile}' already exists ({existing.username} on {existing.instance_url}). Overwrite?"
49
+ )
50
+ if not confirmed:
51
+ raise SystemExit(0)
52
+
53
+ try:
54
+ run_async(_do_init(instance, profile))
55
+ except NetworkError as exc:
56
+ die(f"Network error: could not reach {instance}. {exc.message or ''}")
57
+ except UnauthorizedError:
58
+ die("Authorization code was rejected. Please try `taibai init` again.")
59
+ except ActivityPubError as exc:
60
+ die(f"Fediverse error: {exc.message or str(exc)}")
61
+ except httpx.ConnectError:
62
+ die(f"Could not connect to {instance}. Check the URL and your network connection.")
63
+
64
+
65
+ async def _do_init(instance_url: str, profile_name: str) -> None:
66
+ """Perform the OAuth flow and save credentials."""
67
+ async with httpx.AsyncClient() as http_client:
68
+ client_id, client_secret = await APClient.create_app(
69
+ instance_url,
70
+ http_client,
71
+ user_agent=_USER_AGENT,
72
+ client_website=_CLIENT_WEBSITE,
73
+ )
74
+
75
+ auth_url = await APClient.generate_authorization_url(instance_url, client_id)
76
+
77
+ console.print(f"\nOpen this URL in your browser:\n\n [link={auth_url}]{auth_url}[/link]\n")
78
+
79
+ code = Prompt.ask("Paste the authorization code")
80
+ code = code.strip()
81
+
82
+ access_token = await APClient.validate_authorization_code(
83
+ http_client,
84
+ instance_url,
85
+ code,
86
+ client_id,
87
+ client_secret,
88
+ )
89
+
90
+ ap = await APClient.create(instance_url, http_client, access_token)
91
+ account = await ap.verify_credentials()
92
+
93
+ save_profile(
94
+ Profile(
95
+ name=profile_name,
96
+ instance_url=instance_url,
97
+ client_id=client_id,
98
+ client_secret=client_secret,
99
+ username=account.acct or "",
100
+ )
101
+ )
102
+ save_token(profile_name, access_token)
103
+
104
+ console.print(
105
+ Panel(
106
+ f"[bold]@{account.acct}[/bold]\n[dim]{instance_url}[/dim]",
107
+ title="[green]Connected[/green]",
108
+ border_style="green",
109
+ )
110
+ )
111
+ console.print(f"Profile [bold]{profile_name!r}[/bold] saved.")
@@ -0,0 +1,130 @@
1
+ """The `notifications` command: display unread (or all) notifications."""
2
+
3
+ from html.parser import HTMLParser
4
+ from typing import Annotated
5
+
6
+ import httpx
7
+ from cyclopts import Parameter
8
+ from longwei import ActivityPubError
9
+ from longwei import APClient
10
+ from longwei import NetworkError
11
+ from longwei import Notification
12
+ from longwei import UnauthorizedError
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from taibai._utils import die
17
+ from taibai._utils import run_async
18
+ from taibai.config import get_profile
19
+ from taibai.config import update_last_seen_notification
20
+ from taibai.credentials import load_token
21
+
22
+ console = Console()
23
+
24
+
25
+ class _HTMLStripper(HTMLParser):
26
+ """Minimal HTML tag stripper using stdlib html.parser."""
27
+
28
+ def __init__(self) -> None:
29
+ """Initialise with an empty text buffer."""
30
+ super().__init__()
31
+ self._parts: list[str] = []
32
+
33
+ def handle_data(self, data: str) -> None:
34
+ """Accumulate text nodes."""
35
+ self._parts.append(data)
36
+
37
+ def get_text(self) -> str:
38
+ """Return the accumulated plain text."""
39
+ return "".join(self._parts)
40
+
41
+
42
+ def _strip_html(html: str) -> str:
43
+ """Strip all HTML tags from a string and return plain text."""
44
+ stripper = _HTMLStripper()
45
+ stripper.feed(html)
46
+ return stripper.get_text()
47
+
48
+
49
+ def notifications_command(
50
+ all_notifications: Annotated[
51
+ bool, Parameter(name=["--all"], negative="", help="Show all notifications, not just new ones.")
52
+ ] = False,
53
+ limit: Annotated[
54
+ int | None, Parameter(name=["--limit", "-n"], help="Maximum number of notifications to fetch.")
55
+ ] = None,
56
+ profile: Annotated[
57
+ str, Parameter(name=["--profile", "-p"], help="Profile name.", env_var="TAIBAI_PROFILE")
58
+ ] = "default",
59
+ ) -> None:
60
+ """Show notifications. Defaults to unread only; use --all to show everything."""
61
+ stored_profile = get_profile(profile)
62
+ if stored_profile is None:
63
+ die(f"Profile '{profile}' not found. Run `taibai init --profile {profile}`.")
64
+ return
65
+
66
+ access_token = load_token(profile)
67
+ if access_token is None:
68
+ die(f"No credentials for profile '{profile}'. Run `taibai init --profile {profile}`.")
69
+ return
70
+
71
+ since_id = None if all_notifications else stored_profile.last_seen_notification_id
72
+
73
+ try:
74
+ run_async(
75
+ _do_notifications(
76
+ stored_profile.instance_url,
77
+ access_token,
78
+ profile,
79
+ since_id=since_id,
80
+ limit=limit,
81
+ )
82
+ )
83
+ except UnauthorizedError:
84
+ die(f"Access token rejected. Re-run `taibai init --profile {profile}`.")
85
+ except NetworkError as exc:
86
+ die(f"Network error: could not reach {stored_profile.instance_url}. {exc.message or ''}")
87
+ except ActivityPubError as exc:
88
+ die(f"Fediverse error: {exc.message or str(exc)}")
89
+ except httpx.ConnectError:
90
+ die(f"Could not connect to {stored_profile.instance_url}.")
91
+
92
+
93
+ async def _do_notifications(
94
+ instance_url: str,
95
+ access_token: str,
96
+ profile_name: str,
97
+ since_id: str | None,
98
+ limit: int | None,
99
+ ) -> None:
100
+ """Fetch and display notifications, updating last-seen state."""
101
+ async with httpx.AsyncClient() as http_client:
102
+ ap = await APClient.create(instance_url, http_client, access_token)
103
+ notifications: list[Notification] = await ap.get_notifications(
104
+ since_id=since_id,
105
+ limit=limit,
106
+ )
107
+
108
+ if not notifications:
109
+ console.print("[dim]No new notifications.[/dim]")
110
+ return
111
+
112
+ table = Table(show_header=True, header_style="bold magenta", show_lines=False)
113
+ table.add_column("Type", style="cyan", width=14, no_wrap=True)
114
+ table.add_column("From", style="green", width=28, no_wrap=True)
115
+ table.add_column("Preview", overflow="fold")
116
+ table.add_column("Time", style="dim", width=16, no_wrap=True)
117
+
118
+ for notif in notifications:
119
+ from_acct = notif.account.acct if notif.account else "unknown"
120
+ preview = ""
121
+ if notif.status is not None and notif.status.content:
122
+ preview = _strip_html(notif.status.content)[:100]
123
+ time_str = (notif.created_at[:16] if notif.created_at else "").replace("T", " ")
124
+ table.add_row(notif.type, f"@{from_acct}", preview, time_str)
125
+
126
+ console.print(table)
127
+
128
+ # Update last-seen to the first (newest) notification ID.
129
+ if notifications[0].id is not None:
130
+ update_last_seen_notification(profile_name, notifications[0].id)
@@ -0,0 +1,293 @@
1
+ """The `post` command: publish a status."""
2
+
3
+ import mimetypes
4
+ import sys
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import httpx
10
+ from cyclopts import Parameter
11
+ from longwei import ActivityPubError
12
+ from longwei import APClient
13
+ from longwei import NetworkError
14
+ from longwei import RatelimitError
15
+ from longwei import UnauthorizedError
16
+ from longwei import Visibility
17
+ from rich.console import Console
18
+
19
+ from taibai._utils import die
20
+ from taibai._utils import run_async
21
+ from taibai.config import get_profile
22
+ from taibai.credentials import load_token
23
+
24
+ console = Console()
25
+
26
+
27
+ def _ratelimit_message(exc: RatelimitError) -> str:
28
+ """Return a human-readable rate-limit error message."""
29
+ if exc.ratelimit_reset is not None:
30
+ return f"Rate limit exceeded. Retry after {exc.ratelimit_reset.strftime('%H:%M:%S UTC')}."
31
+ return "Rate limit exceeded. Please try again later."
32
+
33
+
34
+ _VISIBILITY_MAP: dict[str, Visibility] = {
35
+ "public": Visibility.PUBLIC,
36
+ "unlisted": Visibility.UNLISTED,
37
+ "private": Visibility.PRIVATE,
38
+ "direct": Visibility.DIRECT,
39
+ }
40
+
41
+
42
+ def _resolve_text(text: str | None, file: Path | None) -> str:
43
+ """Resolve status text from positional arg, --file, or stdin.
44
+
45
+ Args:
46
+ text: Positional text argument (None if omitted, '-' for stdin).
47
+ file: Optional path to a text file containing the status body.
48
+
49
+ Returns:
50
+ The resolved status text string.
51
+
52
+ """
53
+ if file is not None:
54
+ if text is not None:
55
+ die("Cannot combine --file with a positional text argument or stdin ('-').")
56
+ return file.read_text(encoding="utf-8")
57
+ if text == "-":
58
+ return sys.stdin.read()
59
+ if text is not None:
60
+ return text
61
+ die("Provide status text, --file PATH, or pipe via stdin ('-').")
62
+ return "" # unreachable; die() raises SystemExit
63
+
64
+
65
+ def _validate_post_options(poll_options: Sequence[str], attach: Sequence[str], poll_expires: int) -> None:
66
+ """Validate poll and attachment option combinations.
67
+
68
+ Args:
69
+ poll_options: Poll option strings.
70
+ attach: File paths to attach.
71
+ poll_expires: Poll expiry duration in seconds.
72
+
73
+ """
74
+ if len(poll_options) == 1:
75
+ die("A poll requires at least 2 options.")
76
+ if len(poll_options) > 4:
77
+ die("Mastodon supports at most 4 poll options.")
78
+ if poll_options and attach:
79
+ die("Cannot combine a poll with media attachments (Mastodon limitation).")
80
+ if poll_options and poll_expires < 300:
81
+ die("Poll expiry must be at least 300 seconds (5 minutes).")
82
+
83
+
84
+ async def _upload_attachments(
85
+ ap: APClient,
86
+ attach: Sequence[str],
87
+ alt_texts: Sequence[str],
88
+ ) -> list[str]:
89
+ """Upload media attachments and return their IDs.
90
+
91
+ Args:
92
+ ap: Authenticated APClient instance.
93
+ attach: Local file paths to upload.
94
+ alt_texts: Alt-text strings, paired positionally with attach.
95
+
96
+ Returns:
97
+ List of media attachment IDs ready to pass to post_status().
98
+
99
+ """
100
+ media_ids: list[str] = []
101
+ for i, path_str in enumerate(attach):
102
+ alt: str | None = alt_texts[i] if i < len(alt_texts) else None
103
+ mime, _ = mimetypes.guess_type(path_str)
104
+ with Path(path_str).open("rb") as fh:
105
+ attachment = await ap.post_media(fh, mime_type=mime or "application/octet-stream", description=alt)
106
+ if attachment.id is None:
107
+ die(f"Server did not return an ID for attachment '{path_str}'.")
108
+ return media_ids # unreachable
109
+ media_ids.append(attachment.id)
110
+ return media_ids
111
+
112
+
113
+ def _resolve_visibility(visibility: str) -> Visibility:
114
+ """Resolve a visibility string to a Visibility enum value, dying on invalid input.
115
+
116
+ Args:
117
+ visibility: One of 'public', 'unlisted', 'private', or 'direct'.
118
+
119
+ Returns:
120
+ The matching Visibility enum value.
121
+
122
+ """
123
+ if visibility not in _VISIBILITY_MAP:
124
+ die(f"Invalid visibility {visibility!r}. Choose from: {', '.join(_VISIBILITY_MAP)}.")
125
+ return _VISIBILITY_MAP[visibility] # type: ignore[return-value]
126
+
127
+
128
+ def _post_impl( # noqa: PLR0913
129
+ text: str | None,
130
+ file: Path | None,
131
+ visibility: str,
132
+ cw: str | None,
133
+ attach: list[str],
134
+ alt_text: list[str],
135
+ sensitive: bool,
136
+ poll_option: list[str],
137
+ poll_expires: int,
138
+ poll_multiple: bool,
139
+ poll_hide_totals: bool,
140
+ profile: str,
141
+ ) -> None:
142
+ """Execute post logic: resolve text, validate options, load credentials, run async call.
143
+
144
+ Args:
145
+ text: Raw text argument (may be None or '-').
146
+ file: Optional path to read status text from.
147
+ visibility: Visibility string ('public', 'unlisted', etc.).
148
+ cw: Optional content warning / spoiler text.
149
+ attach: Local file paths to upload as media.
150
+ alt_text: Alt texts positionally paired with attach.
151
+ sensitive: Whether to mark media as sensitive.
152
+ poll_option: Poll option strings.
153
+ poll_expires: Poll duration in seconds.
154
+ poll_multiple: Allow multiple selections.
155
+ poll_hide_totals: Hide vote totals until closed.
156
+ profile: Profile name to use.
157
+
158
+ """
159
+ resolved_text = _resolve_text(text, file)
160
+ _validate_post_options(poll_option, attach, poll_expires)
161
+ vis = _resolve_visibility(visibility)
162
+
163
+ stored_profile = get_profile(profile)
164
+ if stored_profile is None:
165
+ die(f"Profile '{profile}' not found. Run `taibai init --profile {profile}`.")
166
+ return
167
+
168
+ access_token = load_token(profile)
169
+ if access_token is None:
170
+ die(f"No credentials for profile '{profile}'. Run `taibai init --profile {profile}`.")
171
+ return
172
+
173
+ try:
174
+ run_async(
175
+ _do_post(
176
+ stored_profile.instance_url,
177
+ access_token,
178
+ resolved_text,
179
+ vis,
180
+ cw,
181
+ attach=attach,
182
+ alt_texts=alt_text,
183
+ sensitive=sensitive,
184
+ poll_options=poll_option,
185
+ poll_expires_in=poll_expires,
186
+ poll_multiple=poll_multiple,
187
+ poll_hide_totals=poll_hide_totals,
188
+ )
189
+ )
190
+ except OSError as exc:
191
+ die(f"Could not read attachment: {exc}")
192
+ except UnauthorizedError:
193
+ die(f"Access token rejected. Re-run `taibai init --profile {profile}`.")
194
+ except RatelimitError as exc:
195
+ die(_ratelimit_message(exc))
196
+ except NetworkError as exc:
197
+ die(f"Network error: could not reach {stored_profile.instance_url}. {exc.message or ''}")
198
+ except ActivityPubError as exc:
199
+ die(f"Fediverse error: {exc.message or str(exc)}")
200
+ except httpx.ConnectError:
201
+ die(f"Could not connect to {stored_profile.instance_url}.")
202
+
203
+
204
+ def post_command( # noqa: PLR0913
205
+ text: Annotated[
206
+ str | None, Parameter(help="Text to post. Use '-' to read from stdin.", allow_leading_hyphen=True)
207
+ ] = None,
208
+ visibility: Annotated[
209
+ str, Parameter(name=["--visibility", "-v"], help="Visibility: public, unlisted, private, or direct.")
210
+ ] = "public",
211
+ cw: Annotated[str | None, Parameter(name=["--cw"], help="Content warning / subject line.")] = None,
212
+ file: Annotated[Path | None, Parameter(name=["--file"], help="Read status text from this file.")] = None,
213
+ attach: Annotated[
214
+ list[str] | None,
215
+ Parameter(name=["--attach"], help="Media file to attach (repeatable; server enforces max)."),
216
+ ] = None,
217
+ alt_text: Annotated[
218
+ list[str] | None,
219
+ Parameter(name=["--alt-text"], help="Alt text for the corresponding --attach (positionally paired)."),
220
+ ] = None,
221
+ sensitive: Annotated[bool, Parameter(name=["--sensitive"], help="Mark attached media as sensitive.")] = False,
222
+ poll_option: Annotated[
223
+ list[str] | None,
224
+ Parameter(name=["--poll-option"], help="Add a poll option (repeatable, min 2, max 4)."),
225
+ ] = None,
226
+ poll_expires: Annotated[
227
+ int, Parameter(name=["--poll-expires"], help="Poll duration in seconds (default: 86400, min: 300).")
228
+ ] = 86400,
229
+ poll_multiple: Annotated[
230
+ bool, Parameter(name=["--poll-multiple"], help="Allow voters to select multiple options.")
231
+ ] = False,
232
+ poll_hide_totals: Annotated[
233
+ bool, Parameter(name=["--poll-hide-totals"], help="Hide vote totals until poll closes.")
234
+ ] = False,
235
+ profile: Annotated[
236
+ str, Parameter(name=["--profile", "-p"], help="Profile name.", env_var="TAIBAI_PROFILE")
237
+ ] = "default",
238
+ ) -> None:
239
+ """Publish a status to the Fediverse."""
240
+ _post_impl(
241
+ text=text,
242
+ file=file,
243
+ visibility=visibility,
244
+ cw=cw,
245
+ attach=attach or [],
246
+ alt_text=alt_text or [],
247
+ sensitive=sensitive,
248
+ poll_option=poll_option or [],
249
+ poll_expires=poll_expires,
250
+ poll_multiple=poll_multiple,
251
+ poll_hide_totals=poll_hide_totals,
252
+ profile=profile,
253
+ )
254
+
255
+
256
+ async def _do_post( # noqa: PLR0913
257
+ instance_url: str,
258
+ access_token: str,
259
+ text: str,
260
+ visibility: Visibility,
261
+ cw: str | None,
262
+ *,
263
+ attach: Sequence[str],
264
+ alt_texts: Sequence[str],
265
+ sensitive: bool,
266
+ poll_options: Sequence[str],
267
+ poll_expires_in: int,
268
+ poll_multiple: bool,
269
+ poll_hide_totals: bool,
270
+ ) -> None:
271
+ """Upload any attachments then post the status and print its URL."""
272
+ async with httpx.AsyncClient() as http_client:
273
+ ap = await APClient.create(instance_url, http_client, access_token)
274
+
275
+ if len(attach) > ap.max_attachments:
276
+ die(f"Cannot attach more than {ap.max_attachments} files on this instance.")
277
+
278
+ media_ids = await _upload_attachments(ap, attach, alt_texts) if attach else []
279
+
280
+ status = await ap.post_status(
281
+ text,
282
+ visibility=visibility,
283
+ spoiler_text=cw,
284
+ media_ids=media_ids or None,
285
+ sensitive=sensitive if media_ids else False,
286
+ poll_options=list(poll_options) if poll_options else None,
287
+ poll_expires_in=poll_expires_in if poll_options else None,
288
+ poll_multiple=poll_multiple if poll_options else False,
289
+ poll_hide_totals=poll_hide_totals if poll_options else False,
290
+ )
291
+
292
+ url = status.url or status.uri
293
+ console.print(f"Posted: [link={url}]{url}[/link]")
@@ -0,0 +1,66 @@
1
+ """The `whoami` command: verify credentials and display the current account."""
2
+
3
+ from typing import Annotated
4
+
5
+ import httpx
6
+ from cyclopts import Parameter
7
+ from longwei import ActivityPubError
8
+ from longwei import APClient
9
+ from longwei import NetworkError
10
+ from longwei import UnauthorizedError
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.text import Text
14
+
15
+ from taibai._utils import die
16
+ from taibai._utils import run_async
17
+ from taibai.config import get_profile
18
+ from taibai.credentials import load_token
19
+
20
+ console = Console()
21
+
22
+
23
+ def whoami_command(
24
+ profile: Annotated[
25
+ str, Parameter(name=["--profile", "-p"], help="Profile name.", env_var="TAIBAI_PROFILE")
26
+ ] = "default",
27
+ ) -> None:
28
+ """Verify stored credentials and display the current account."""
29
+ stored_profile = get_profile(profile)
30
+ if stored_profile is None:
31
+ die(f"Profile '{profile}' not found. Run `taibai init --profile {profile}`.")
32
+ return
33
+
34
+ access_token = load_token(profile)
35
+ if access_token is None:
36
+ die(f"No credentials for profile '{profile}'. Run `taibai init --profile {profile}`.")
37
+ return
38
+
39
+ try:
40
+ run_async(_do_whoami(stored_profile.instance_url, access_token))
41
+ except UnauthorizedError:
42
+ die(f"Access token rejected. Re-run `taibai init --profile {profile}`.")
43
+ except NetworkError as exc:
44
+ die(f"Network error: could not reach {stored_profile.instance_url}. {exc.message or ''}")
45
+ except ActivityPubError as exc:
46
+ die(f"Fediverse error: {exc.message or str(exc)}")
47
+ except httpx.ConnectError:
48
+ die(f"Could not connect to {stored_profile.instance_url}.")
49
+
50
+
51
+ async def _do_whoami(instance_url: str, access_token: str) -> None:
52
+ """Fetch and display account info."""
53
+ async with httpx.AsyncClient() as http_client:
54
+ ap = await APClient.create(instance_url, http_client, access_token)
55
+ account = await ap.verify_credentials()
56
+
57
+ display = account.display_name or account.username or account.acct
58
+ text = Text()
59
+ text.append(f"{display}\n", style="bold")
60
+ text.append(f"@{account.acct}\n", style="green")
61
+ text.append(f"{instance_url}\n\n", style="dim")
62
+ text.append(
63
+ f"Followers: {account.followers_count} Following: {account.following_count} Posts: {account.statuses_count}"
64
+ )
65
+
66
+ console.print(Panel(text, title="Account Info", border_style="blue"))
@@ -0,0 +1,96 @@
1
+ """Profile configuration management for taibai."""
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import asdict
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import platformdirs
10
+
11
+
12
+ @dataclass
13
+ class Profile:
14
+ """A named account profile storing instance and app registration data."""
15
+
16
+ name: str
17
+ instance_url: str
18
+ client_id: str
19
+ client_secret: str
20
+ username: str
21
+ last_seen_notification_id: str | None = None
22
+
23
+
24
+ def config_path() -> Path:
25
+ """Return the path to config.json, creating the parent directory if needed."""
26
+ config_dir = Path(platformdirs.user_config_dir("taibai", ensure_exists=True))
27
+ return config_dir / "config.json"
28
+
29
+
30
+ def load_config() -> dict:
31
+ """Load and return the raw config dict. Returns empty dict if file absent."""
32
+ path = config_path()
33
+ if not path.exists():
34
+ return {}
35
+ with path.open("r", encoding="utf-8") as fh:
36
+ return json.load(fh)
37
+
38
+
39
+ def save_config(config: dict) -> None:
40
+ """Atomically write the config dict to config.json."""
41
+ path = config_path()
42
+ tmp = path.with_suffix(".json.tmp")
43
+ with tmp.open("w", encoding="utf-8") as fh:
44
+ json.dump(config, fh, indent=2)
45
+ fh.write("\n")
46
+ os.replace(tmp, path)
47
+
48
+
49
+ def get_profile(name: str) -> Profile | None:
50
+ """Return the named Profile, or None if it does not exist."""
51
+ config = load_config()
52
+ raw = config.get("profiles", {}).get(name)
53
+ if raw is None:
54
+ return None
55
+ return Profile(**raw)
56
+
57
+
58
+ def save_profile(profile: Profile) -> None:
59
+ """Insert or update a profile in config.json."""
60
+ config = load_config()
61
+ config.setdefault("profiles", {})[profile.name] = asdict(profile)
62
+ if "default_profile" not in config:
63
+ config["default_profile"] = profile.name
64
+ save_config(config)
65
+
66
+
67
+ def delete_profile(name: str) -> None:
68
+ """Remove a profile from config.json. Silent if not found."""
69
+ config = load_config()
70
+ config.get("profiles", {}).pop(name, None)
71
+ if config.get("default_profile") == name:
72
+ remaining = list(config.get("profiles", {}).keys())
73
+ config["default_profile"] = remaining[0] if remaining else None
74
+ save_config(config)
75
+
76
+
77
+ def get_default_profile_name() -> str | None:
78
+ """Return the name of the default profile, or None if no profiles exist."""
79
+ config = load_config()
80
+ return config.get("default_profile")
81
+
82
+
83
+ def set_default_profile(name: str) -> None:
84
+ """Set the named profile as the default."""
85
+ config = load_config()
86
+ config["default_profile"] = name
87
+ save_config(config)
88
+
89
+
90
+ def update_last_seen_notification(profile_name: str, notification_id: str) -> None:
91
+ """Update last_seen_notification_id for a profile."""
92
+ config = load_config()
93
+ profiles = config.get("profiles", {})
94
+ if profile_name in profiles:
95
+ profiles[profile_name]["last_seen_notification_id"] = notification_id
96
+ save_config(config)
@@ -0,0 +1,94 @@
1
+ """Credential storage for taibai.
2
+
3
+ Tokens are stored in the OS keyring where available. On headless systems
4
+ (no SecretService / D-Bus), the module falls back to the config file and
5
+ prints a one-time warning. The fallback is plaintext; users can migrate to a
6
+ real keyring at any time by re-running `taibai init`.
7
+ """
8
+
9
+ import logging
10
+
11
+ import keyring
12
+ import keyring.errors
13
+ from rich.console import Console
14
+
15
+ from taibai.config import config_path
16
+ from taibai.config import load_config
17
+ from taibai.config import save_config
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _KEYRING_SERVICE = "taibai"
22
+ _WARNED: set[str] = set() # profiles for which the headless warning was printed
23
+ _err = Console(stderr=True)
24
+
25
+
26
+ def _key(profile_name: str) -> str:
27
+ """Return the keyring username key for a profile."""
28
+ return f"taibai::{profile_name}"
29
+
30
+
31
+ def _warn_headless(profile_name: str) -> None:
32
+ """Print a one-time warning that the file fallback is active."""
33
+ if profile_name not in _WARNED:
34
+ _WARNED.add(profile_name)
35
+ _err.print(
36
+ "[yellow]Warning:[/yellow] No keyring backend available. "
37
+ f"Token for profile '{profile_name}' is stored in plaintext at "
38
+ f"{config_path()} — protect this file accordingly."
39
+ )
40
+
41
+
42
+ # ── file-based fallback ────────────────────────────────────────────────────
43
+
44
+
45
+ def _save_fallback(profile_name: str, access_token: str) -> None:
46
+ """Persist token in config.json under _tokens."""
47
+ cfg = load_config()
48
+ cfg.setdefault("_tokens", {})[profile_name] = access_token
49
+ save_config(cfg)
50
+
51
+
52
+ def _load_fallback(profile_name: str) -> str | None:
53
+ """Load token from config.json fallback store."""
54
+ return load_config().get("_tokens", {}).get(profile_name)
55
+
56
+
57
+ def _delete_fallback(profile_name: str) -> None:
58
+ """Remove token from config.json fallback store."""
59
+ cfg = load_config()
60
+ cfg.get("_tokens", {}).pop(profile_name, None)
61
+ save_config(cfg)
62
+
63
+
64
+ # ── public API ─────────────────────────────────────────────────────────────
65
+
66
+
67
+ def save_token(profile_name: str, access_token: str) -> None:
68
+ """Save an access token to the OS keyring, falling back to config file."""
69
+ try:
70
+ keyring.set_password(_KEYRING_SERVICE, _key(profile_name), access_token)
71
+ except Exception as exc: # broad catch intentional: covers dbus/headless variants
72
+ logger.debug("Keyring unavailable for save (%s); using file fallback.", exc)
73
+ _warn_headless(profile_name)
74
+ _save_fallback(profile_name, access_token)
75
+
76
+
77
+ def load_token(profile_name: str) -> str | None:
78
+ """Load an access token. Checks keyring first, then config-file fallback."""
79
+ try:
80
+ token = keyring.get_password(_KEYRING_SERVICE, _key(profile_name))
81
+ if token is not None:
82
+ return token
83
+ except Exception as exc: # broad catch intentional: covers dbus/headless variants
84
+ logger.debug("Keyring unavailable for load (%s); using file fallback.", exc)
85
+ return _load_fallback(profile_name)
86
+
87
+
88
+ def delete_token(profile_name: str) -> None:
89
+ """Delete an access token from keyring and config-file fallback."""
90
+ try:
91
+ keyring.delete_password(_KEYRING_SERVICE, _key(profile_name))
92
+ except Exception as exc: # broad catch intentional: covers PasswordDeleteError + dbus variants
93
+ logger.debug("Keyring delete skipped (%s).", exc)
94
+ _delete_fallback(profile_name)