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 +61 -0
- taibai-0.2.0/README.md +31 -0
- taibai-0.2.0/pyproject.toml +83 -0
- taibai-0.2.0/src/taibai/__init__.py +1 -0
- taibai-0.2.0/src/taibai/_utils.py +20 -0
- taibai-0.2.0/src/taibai/cli.py +18 -0
- taibai-0.2.0/src/taibai/commands/__init__.py +1 -0
- taibai-0.2.0/src/taibai/commands/init.py +111 -0
- taibai-0.2.0/src/taibai/commands/notifications.py +130 -0
- taibai-0.2.0/src/taibai/commands/post.py +293 -0
- taibai-0.2.0/src/taibai/commands/whoami.py +66 -0
- taibai-0.2.0/src/taibai/config.py +96 -0
- taibai-0.2.0/src/taibai/credentials.py +94 -0
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)
|