magiccampus-im-sdk 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.
- magiccampus_im_sdk-0.1.0/PKG-INFO +128 -0
- magiccampus_im_sdk-0.1.0/README.md +115 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/__init__.py +1 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/__main__.py +9 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/accounts.py +126 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/cli.py +105 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/client.py +84 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/commands/auth.py +26 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/commands/config.py +95 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/commands/notify.py +90 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/config_store.py +57 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/notify.py +416 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/output.py +101 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/prompts.py +58 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/targets.py +26 -0
- magiccampus_im_sdk-0.1.0/magiccampus_cli/utils.py +67 -0
- magiccampus_im_sdk-0.1.0/magiccampus_im_sdk.egg-info/PKG-INFO +128 -0
- magiccampus_im_sdk-0.1.0/magiccampus_im_sdk.egg-info/SOURCES.txt +36 -0
- magiccampus_im_sdk-0.1.0/magiccampus_im_sdk.egg-info/dependency_links.txt +1 -0
- magiccampus_im_sdk-0.1.0/magiccampus_im_sdk.egg-info/entry_points.txt +2 -0
- magiccampus_im_sdk-0.1.0/magiccampus_im_sdk.egg-info/requires.txt +5 -0
- magiccampus_im_sdk-0.1.0/magiccampus_im_sdk.egg-info/top_level.txt +2 -0
- magiccampus_im_sdk-0.1.0/magiccampus_sdk/__init__.py +81 -0
- magiccampus_im_sdk-0.1.0/magiccampus_sdk/auth.py +111 -0
- magiccampus_im_sdk-0.1.0/magiccampus_sdk/client.py +89 -0
- magiccampus_im_sdk-0.1.0/magiccampus_sdk/example.py +46 -0
- magiccampus_im_sdk-0.1.0/magiccampus_sdk/example_ws.py +59 -0
- magiccampus_im_sdk-0.1.0/magiccampus_sdk/ws_client.py +107 -0
- magiccampus_im_sdk-0.1.0/pyproject.toml +39 -0
- magiccampus_im_sdk-0.1.0/setup.cfg +4 -0
- magiccampus_im_sdk-0.1.0/tests/test_auth.py +64 -0
- magiccampus_im_sdk-0.1.0/tests/test_auth_status.py +57 -0
- magiccampus_im_sdk-0.1.0/tests/test_cli_config.py +39 -0
- magiccampus_im_sdk-0.1.0/tests/test_cli_interactive.py +34 -0
- magiccampus_im_sdk-0.1.0/tests/test_cli_main.py +21 -0
- magiccampus_im_sdk-0.1.0/tests/test_cli_notify.py +170 -0
- magiccampus_im_sdk-0.1.0/tests/test_smoke_cli.py +53 -0
- magiccampus_im_sdk-0.1.0/tests/test_ws_client.py +73 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: magiccampus-im-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MagicCampus-flavoured SDK and CLI built on top of openim-sdk-core.
|
|
5
|
+
Author: MagicCampus Contributors
|
|
6
|
+
License-Expression: AGPL-3.0-or-later
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: httpx<1,>=0.27
|
|
10
|
+
Requires-Dist: openim-sdk-core>=0.1.10
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# magiccampus-im-sdk
|
|
15
|
+
|
|
16
|
+
Python SDK and CLI for MagicCampus login, messaging, and websocket sync.
|
|
17
|
+
|
|
18
|
+
This project keeps the OpenIM transport implementation in `openim_sdk`, but removes direct user-facing dependency on OpenIM token and endpoint configuration. You only configure:
|
|
19
|
+
|
|
20
|
+
- `apiurl`
|
|
21
|
+
- `apikey`
|
|
22
|
+
- `platform`
|
|
23
|
+
|
|
24
|
+
At runtime, the SDK calls:
|
|
25
|
+
|
|
26
|
+
`GET /api/v1/accounts/imToken?platform=<PLATFORM>`
|
|
27
|
+
|
|
28
|
+
and uses the response fields:
|
|
29
|
+
|
|
30
|
+
- `userId`
|
|
31
|
+
- `imToken`
|
|
32
|
+
- `wsApi`
|
|
33
|
+
- `httpApi`
|
|
34
|
+
|
|
35
|
+
## Install locally
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv sync
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This repository is configured to resolve `openim-sdk-core` from the sibling directory `../openim-python-sdk`.
|
|
42
|
+
|
|
43
|
+
## CLI quick start
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
magiccampus-cli --help
|
|
47
|
+
magiccampus-cli config init --apiurl https://api-test.pearlapi.com --apikey "$MAGICCAMPUS_APIKEY" --platform IOS --default true
|
|
48
|
+
magiccampus-cli auth status --format pretty
|
|
49
|
+
magiccampus-cli notify +send --to user:10134002 --text "hello"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`magiccampus-cli config init` also supports an interactive TTY flow when you omit auth flags.
|
|
53
|
+
|
|
54
|
+
CLI config is stored at:
|
|
55
|
+
|
|
56
|
+
`~/.mushroom_agent/auth/magiccampus-im-sdk.json`
|
|
57
|
+
|
|
58
|
+
Supported commands:
|
|
59
|
+
|
|
60
|
+
- `config init|list|use|remove`
|
|
61
|
+
- `auth status`
|
|
62
|
+
- `notify +send`
|
|
63
|
+
- `notify +history`
|
|
64
|
+
|
|
65
|
+
## Python websocket quick start
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from magiccampus_sdk import MagicCampusWSSDK, MagicCampusWSConfig
|
|
69
|
+
|
|
70
|
+
sdk = MagicCampusWSSDK(
|
|
71
|
+
MagicCampusWSConfig(
|
|
72
|
+
apiurl="https://api-test.pearlapi.com",
|
|
73
|
+
apikey="YOUR_API_KEY",
|
|
74
|
+
platform="IOS",
|
|
75
|
+
data_dir="./magiccampus_data",
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
sdk.login()
|
|
80
|
+
sdk.start()
|
|
81
|
+
sdk.send_text("hello", recv_id="10134002")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Runnable examples live in [magiccampus_sdk/example.py](magiccampus_sdk/example.py) and [magiccampus_sdk/example_ws.py](magiccampus_sdk/example_ws.py).
|
|
85
|
+
|
|
86
|
+
## CLI smoke script
|
|
87
|
+
|
|
88
|
+
An executable smoke helper lives at [scripts/smoke_cli.py](scripts/smoke_cli.py).
|
|
89
|
+
|
|
90
|
+
Offline-safe mode only exercises config persistence and notify dry-run:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
uv run python scripts/smoke_cli.py
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This mode does not require a real API key. It uses a placeholder key by default and avoids any live auth call.
|
|
97
|
+
|
|
98
|
+
## Real network mode
|
|
99
|
+
|
|
100
|
+
To run the live auth probe, provide a real API key and enable `--live`:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
export MAGICCAMPUS_APIURL=https://api-test.pearlapi.com
|
|
104
|
+
export MAGICCAMPUS_APIKEY='your real api key'
|
|
105
|
+
export MAGICCAMPUS_PLATFORM=IOS
|
|
106
|
+
uv run python scripts/smoke_cli.py --live
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Optional variables:
|
|
110
|
+
|
|
111
|
+
- `MAGICCAMPUS_SMOKE_TARGET`: target used by dry-run and optional live send, default `user:smoke-target`
|
|
112
|
+
- `MAGICCAMPUS_PLATFORM`: defaults to `IOS`
|
|
113
|
+
|
|
114
|
+
If you also want to send a real text message, set a real target and pass `--live-send`:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
export MAGICCAMPUS_SMOKE_TARGET='user:10134002'
|
|
118
|
+
uv run python scripts/smoke_cli.py --live --live-send --text 'hello from smoke script'
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`--live-send` performs a real outbound send, so use it only with a valid target and credentials.
|
|
122
|
+
|
|
123
|
+
## Notes
|
|
124
|
+
|
|
125
|
+
- `apiurl` can point to any MagicCampus API base URL; the CLI and SDK append `/api/v1/accounts/imToken`
|
|
126
|
+
- `platform` defaults to `IOS`
|
|
127
|
+
- low-level message send / receive behavior comes from `openim_sdk`
|
|
128
|
+
- the wrapper does not persist raw OpenIM credentials in CLI config
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# magiccampus-im-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK and CLI for MagicCampus login, messaging, and websocket sync.
|
|
4
|
+
|
|
5
|
+
This project keeps the OpenIM transport implementation in `openim_sdk`, but removes direct user-facing dependency on OpenIM token and endpoint configuration. You only configure:
|
|
6
|
+
|
|
7
|
+
- `apiurl`
|
|
8
|
+
- `apikey`
|
|
9
|
+
- `platform`
|
|
10
|
+
|
|
11
|
+
At runtime, the SDK calls:
|
|
12
|
+
|
|
13
|
+
`GET /api/v1/accounts/imToken?platform=<PLATFORM>`
|
|
14
|
+
|
|
15
|
+
and uses the response fields:
|
|
16
|
+
|
|
17
|
+
- `userId`
|
|
18
|
+
- `imToken`
|
|
19
|
+
- `wsApi`
|
|
20
|
+
- `httpApi`
|
|
21
|
+
|
|
22
|
+
## Install locally
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv sync
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This repository is configured to resolve `openim-sdk-core` from the sibling directory `../openim-python-sdk`.
|
|
29
|
+
|
|
30
|
+
## CLI quick start
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
magiccampus-cli --help
|
|
34
|
+
magiccampus-cli config init --apiurl https://api-test.pearlapi.com --apikey "$MAGICCAMPUS_APIKEY" --platform IOS --default true
|
|
35
|
+
magiccampus-cli auth status --format pretty
|
|
36
|
+
magiccampus-cli notify +send --to user:10134002 --text "hello"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`magiccampus-cli config init` also supports an interactive TTY flow when you omit auth flags.
|
|
40
|
+
|
|
41
|
+
CLI config is stored at:
|
|
42
|
+
|
|
43
|
+
`~/.mushroom_agent/auth/magiccampus-im-sdk.json`
|
|
44
|
+
|
|
45
|
+
Supported commands:
|
|
46
|
+
|
|
47
|
+
- `config init|list|use|remove`
|
|
48
|
+
- `auth status`
|
|
49
|
+
- `notify +send`
|
|
50
|
+
- `notify +history`
|
|
51
|
+
|
|
52
|
+
## Python websocket quick start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from magiccampus_sdk import MagicCampusWSSDK, MagicCampusWSConfig
|
|
56
|
+
|
|
57
|
+
sdk = MagicCampusWSSDK(
|
|
58
|
+
MagicCampusWSConfig(
|
|
59
|
+
apiurl="https://api-test.pearlapi.com",
|
|
60
|
+
apikey="YOUR_API_KEY",
|
|
61
|
+
platform="IOS",
|
|
62
|
+
data_dir="./magiccampus_data",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
sdk.login()
|
|
67
|
+
sdk.start()
|
|
68
|
+
sdk.send_text("hello", recv_id="10134002")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Runnable examples live in [magiccampus_sdk/example.py](magiccampus_sdk/example.py) and [magiccampus_sdk/example_ws.py](magiccampus_sdk/example_ws.py).
|
|
72
|
+
|
|
73
|
+
## CLI smoke script
|
|
74
|
+
|
|
75
|
+
An executable smoke helper lives at [scripts/smoke_cli.py](scripts/smoke_cli.py).
|
|
76
|
+
|
|
77
|
+
Offline-safe mode only exercises config persistence and notify dry-run:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
uv run python scripts/smoke_cli.py
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This mode does not require a real API key. It uses a placeholder key by default and avoids any live auth call.
|
|
84
|
+
|
|
85
|
+
## Real network mode
|
|
86
|
+
|
|
87
|
+
To run the live auth probe, provide a real API key and enable `--live`:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
export MAGICCAMPUS_APIURL=https://api-test.pearlapi.com
|
|
91
|
+
export MAGICCAMPUS_APIKEY='your real api key'
|
|
92
|
+
export MAGICCAMPUS_PLATFORM=IOS
|
|
93
|
+
uv run python scripts/smoke_cli.py --live
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Optional variables:
|
|
97
|
+
|
|
98
|
+
- `MAGICCAMPUS_SMOKE_TARGET`: target used by dry-run and optional live send, default `user:smoke-target`
|
|
99
|
+
- `MAGICCAMPUS_PLATFORM`: defaults to `IOS`
|
|
100
|
+
|
|
101
|
+
If you also want to send a real text message, set a real target and pass `--live-send`:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
export MAGICCAMPUS_SMOKE_TARGET='user:10134002'
|
|
105
|
+
uv run python scripts/smoke_cli.py --live --live-send --text 'hello from smoke script'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`--live-send` performs a real outbound send, so use it only with a valid target and credentials.
|
|
109
|
+
|
|
110
|
+
## Notes
|
|
111
|
+
|
|
112
|
+
- `apiurl` can point to any MagicCampus API base URL; the CLI and SDK append `/api/v1/accounts/imToken`
|
|
113
|
+
- `platform` defaults to `IOS`
|
|
114
|
+
- low-level message send / receive behavior comes from `openim_sdk`
|
|
115
|
+
- the wrapper does not persist raw OpenIM credentials in CLI config
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from magiccampus_sdk.auth import normalize_apiurl, normalize_platform
|
|
6
|
+
|
|
7
|
+
from .utils import env_string, mask_secret, trim_string
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class AccountConfig:
|
|
12
|
+
account_id: str
|
|
13
|
+
enabled: bool
|
|
14
|
+
apiurl: str
|
|
15
|
+
apikey: str
|
|
16
|
+
platform: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def account_config_to_raw(account: AccountConfig) -> dict[str, object]:
|
|
20
|
+
return {
|
|
21
|
+
"enabled": account.enabled,
|
|
22
|
+
"apiUrl": account.apiurl,
|
|
23
|
+
"apiKey": account.apikey,
|
|
24
|
+
"platform": account.platform,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize_stored_account(raw: object) -> dict[str, object]:
|
|
29
|
+
source = raw if isinstance(raw, dict) else {}
|
|
30
|
+
apiurl = trim_string(source.get("apiUrl") or source.get("apiurl"))
|
|
31
|
+
platform = trim_string(source.get("platform")) or "IOS"
|
|
32
|
+
normalized: dict[str, object] = {
|
|
33
|
+
"enabled": source.get("enabled") is not False,
|
|
34
|
+
"apiUrl": normalize_apiurl(apiurl) if apiurl else "",
|
|
35
|
+
"apiKey": trim_string(source.get("apiKey") or source.get("apikey")),
|
|
36
|
+
"platform": normalize_platform(platform),
|
|
37
|
+
}
|
|
38
|
+
return normalized
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def env_default_account() -> dict[str, object] | None:
|
|
42
|
+
apiurl = env_string("MAGICCAMPUS_APIURL")
|
|
43
|
+
apikey = env_string("MAGICCAMPUS_APIKEY")
|
|
44
|
+
if not apiurl or not apikey:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
"enabled": True,
|
|
49
|
+
"apiUrl": apiurl,
|
|
50
|
+
"apiKey": apikey,
|
|
51
|
+
"platform": env_string("MAGICCAMPUS_PLATFORM") or "IOS",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def normalize_account(account_id: str, raw: object) -> AccountConfig | None:
|
|
56
|
+
stored = normalize_stored_account(raw)
|
|
57
|
+
if not stored["apiUrl"] or not stored["apiKey"]:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
return AccountConfig(
|
|
61
|
+
account_id=account_id,
|
|
62
|
+
enabled=stored.get("enabled") is not False,
|
|
63
|
+
apiurl=str(stored["apiUrl"]),
|
|
64
|
+
apikey=str(stored["apiKey"]),
|
|
65
|
+
platform=str(stored["platform"]),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def list_account_ids(root: dict[str, object]) -> list[str]:
|
|
70
|
+
accounts = root.get("accounts")
|
|
71
|
+
ids = list(accounts.keys()) if isinstance(accounts, dict) else []
|
|
72
|
+
if env_default_account() and "default" not in ids:
|
|
73
|
+
ids.insert(0, "default")
|
|
74
|
+
return ids
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_account_config(root: dict[str, object], account_id: str | None) -> AccountConfig | None:
|
|
78
|
+
resolved_id = account_id or str(root.get("defaultAccountId") or "default")
|
|
79
|
+
if resolved_id == "default":
|
|
80
|
+
env_account = env_default_account()
|
|
81
|
+
if env_account is not None:
|
|
82
|
+
return normalize_account("default", env_account)
|
|
83
|
+
|
|
84
|
+
accounts = root.get("accounts")
|
|
85
|
+
configured = accounts.get(resolved_id) if isinstance(accounts, dict) else None
|
|
86
|
+
if configured is not None:
|
|
87
|
+
return normalize_account(resolved_id, configured)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def list_account_summaries(root: dict[str, object]) -> list[dict[str, object]]:
|
|
92
|
+
summaries: list[dict[str, object]] = []
|
|
93
|
+
default_account_id = str(root.get("defaultAccountId") or "default")
|
|
94
|
+
for account_id in list_account_ids(root):
|
|
95
|
+
account = get_account_config(root, account_id)
|
|
96
|
+
if account is None:
|
|
97
|
+
continue
|
|
98
|
+
summaries.append(
|
|
99
|
+
{
|
|
100
|
+
"accountId": account.account_id,
|
|
101
|
+
"default": account.account_id == default_account_id,
|
|
102
|
+
"enabled": account.enabled,
|
|
103
|
+
"apiUrl": account.apiurl,
|
|
104
|
+
"platform": account.platform,
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
return summaries
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def upsert_stored_account(root: dict[str, object], account_id: str, raw_account: dict[str, object], make_default: bool = False) -> dict[str, object]:
|
|
111
|
+
accounts = root.get("accounts")
|
|
112
|
+
next_accounts = dict(accounts) if isinstance(accounts, dict) else {}
|
|
113
|
+
next_accounts[account_id] = normalize_stored_account(raw_account)
|
|
114
|
+
default_account_id = account_id if make_default else str(root.get("defaultAccountId") or account_id)
|
|
115
|
+
return {"defaultAccountId": default_account_id, "accounts": next_accounts}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def remove_stored_account(root: dict[str, object], account_id: str) -> dict[str, object]:
|
|
119
|
+
accounts = root.get("accounts")
|
|
120
|
+
next_accounts = dict(accounts) if isinstance(accounts, dict) else {}
|
|
121
|
+
next_accounts.pop(account_id, None)
|
|
122
|
+
remaining_ids = list(next_accounts.keys())
|
|
123
|
+
default_account_id = str(root.get("defaultAccountId") or "default")
|
|
124
|
+
if default_account_id == account_id:
|
|
125
|
+
default_account_id = remaining_ids[0] if remaining_ids else "default"
|
|
126
|
+
return {"defaultAccountId": default_account_id, "accounts": next_accounts}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from . import __version__
|
|
6
|
+
from .commands.auth import run_auth_command
|
|
7
|
+
from .commands.config import run_config_command
|
|
8
|
+
from .commands.notify import run_notify_command
|
|
9
|
+
from .output import print_output
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _has_explicit_output_format(argv: list[str]) -> bool:
|
|
13
|
+
return "--format" in argv
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_argv(argv: list[str]) -> tuple[str, list[str]]:
|
|
17
|
+
args = list(argv)
|
|
18
|
+
output_format = "json"
|
|
19
|
+
index = 0
|
|
20
|
+
while index < len(args):
|
|
21
|
+
if args[index] == "--format":
|
|
22
|
+
if index + 1 >= len(args):
|
|
23
|
+
raise RuntimeError("--format requires a value.")
|
|
24
|
+
output_format = args[index + 1]
|
|
25
|
+
del args[index:index + 2]
|
|
26
|
+
continue
|
|
27
|
+
index += 1
|
|
28
|
+
return output_format, args
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_options(argv: list[str]) -> tuple[dict[str, object], list[str]]:
|
|
32
|
+
options: dict[str, object] = {}
|
|
33
|
+
positionals: list[str] = []
|
|
34
|
+
index = 0
|
|
35
|
+
while index < len(argv):
|
|
36
|
+
value = argv[index]
|
|
37
|
+
if value.startswith("--"):
|
|
38
|
+
key = value[2:]
|
|
39
|
+
next_value = argv[index + 1] if index + 1 < len(argv) else None
|
|
40
|
+
if next_value is not None and not next_value.startswith("--"):
|
|
41
|
+
options[key] = next_value
|
|
42
|
+
index += 2
|
|
43
|
+
continue
|
|
44
|
+
options[key] = True
|
|
45
|
+
index += 1
|
|
46
|
+
continue
|
|
47
|
+
positionals.append(value)
|
|
48
|
+
index += 1
|
|
49
|
+
return options, positionals
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def usage() -> str:
|
|
53
|
+
return "\n".join(
|
|
54
|
+
[
|
|
55
|
+
"magiccampus-cli",
|
|
56
|
+
"",
|
|
57
|
+
"Commands:",
|
|
58
|
+
" config init [--account <id>] --apiurl <url> --apikey <token> [--platform <name> --enabled <bool> --default <bool>]",
|
|
59
|
+
" config list",
|
|
60
|
+
" config use <accountId>",
|
|
61
|
+
" config remove <accountId>",
|
|
62
|
+
" auth status [--account <id>]",
|
|
63
|
+
" notify +send --to <user:ID|group:ID> (--text <text> | --image <path|url> | --file <path|url> | --video <path|url>) [--name <filename>] [--account <id>] [--dry-run]",
|
|
64
|
+
" notify +history --to <user:ID|group:ID> [--limit <count>] [--account <id>]",
|
|
65
|
+
"",
|
|
66
|
+
"Global flags:",
|
|
67
|
+
" --format json|pretty|table|ndjson",
|
|
68
|
+
]
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(raw_argv: list[str] | None = None) -> int:
|
|
73
|
+
argv = list(sys.argv[1:] if raw_argv is None else raw_argv)
|
|
74
|
+
try:
|
|
75
|
+
has_explicit_output_format = _has_explicit_output_format(argv)
|
|
76
|
+
output_format, args = parse_argv(argv)
|
|
77
|
+
if not args or "--help" in args or "-h" in args:
|
|
78
|
+
if not has_explicit_output_format:
|
|
79
|
+
sys.stdout.write(f"{usage()}\n")
|
|
80
|
+
return 0
|
|
81
|
+
print_output({"ok": True, "usage": usage()}, output_format)
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
if args[0] == "--version":
|
|
85
|
+
print_output({"ok": True, "version": __version__, "runtime": "python"}, output_format)
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
if args[0] == "config":
|
|
89
|
+
result = run_config_command(args[1] if len(args) > 1 else None, args[2:], parse_options)
|
|
90
|
+
elif args[0] == "auth":
|
|
91
|
+
result = run_auth_command(args[1] if len(args) > 1 else None, args[2:], parse_options)
|
|
92
|
+
elif args[0] == "notify":
|
|
93
|
+
result = run_notify_command(args[1] if len(args) > 1 else None, args[2:], parse_options)
|
|
94
|
+
else:
|
|
95
|
+
raise RuntimeError(f"Unknown command: {args[0]}")
|
|
96
|
+
|
|
97
|
+
print_output(result, output_format)
|
|
98
|
+
return 0
|
|
99
|
+
except Exception as error:
|
|
100
|
+
print_output({"ok": False, "error": str(error)}, output_format if "output_format" in locals() else "json")
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Callable, TypeVar
|
|
6
|
+
|
|
7
|
+
from magiccampus_sdk import MagicCampusWSConfig, MagicCampusWSSDK
|
|
8
|
+
from magiccampus_sdk.auth import resolve_platform_id
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .accounts import AccountConfig
|
|
12
|
+
from .config_store import get_runtime_data_dir
|
|
13
|
+
from .utils import mask_secret
|
|
14
|
+
|
|
15
|
+
T = TypeVar("T")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _build_data_dir(account: AccountConfig) -> Path:
|
|
19
|
+
path = get_runtime_data_dir() / account.account_id
|
|
20
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
return path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_account_db_path(account_id: str, user_id: str) -> Path:
|
|
25
|
+
return get_runtime_data_dir() / account_id / f"OpenIM_pyws_{user_id}.db"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_sdk(account: AccountConfig) -> MagicCampusWSSDK:
|
|
29
|
+
config = MagicCampusWSConfig(
|
|
30
|
+
apiurl=account.apiurl,
|
|
31
|
+
apikey=account.apikey,
|
|
32
|
+
platform=account.platform,
|
|
33
|
+
data_dir=str(_build_data_dir(account)),
|
|
34
|
+
timeout_seconds=30.0,
|
|
35
|
+
sdk_version=f"magiccampus-cli/{__version__}",
|
|
36
|
+
auto_sync_on_connect=False,
|
|
37
|
+
auto_sync_on_reconnect=False,
|
|
38
|
+
auto_reconnect=False,
|
|
39
|
+
enable_heartbeat=False,
|
|
40
|
+
)
|
|
41
|
+
return MagicCampusWSSDK(config)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def with_connected_sdk(account: AccountConfig, action: Callable[[MagicCampusWSSDK], T]) -> T:
|
|
45
|
+
sdk = build_sdk(account)
|
|
46
|
+
sdk.login()
|
|
47
|
+
try:
|
|
48
|
+
sdk.start()
|
|
49
|
+
return action(sdk)
|
|
50
|
+
finally:
|
|
51
|
+
try:
|
|
52
|
+
time.sleep(0.25)
|
|
53
|
+
finally:
|
|
54
|
+
sdk.logout()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_auth_status(account: AccountConfig) -> dict[str, object]:
|
|
58
|
+
def _probe(sdk: MagicCampusWSSDK) -> dict[str, object]:
|
|
59
|
+
credentials = sdk.credentials
|
|
60
|
+
now = int(time.time())
|
|
61
|
+
expire_at = credentials.expire_time if credentials else 0
|
|
62
|
+
return {
|
|
63
|
+
"ok": True,
|
|
64
|
+
"accountId": account.account_id,
|
|
65
|
+
"userID": sdk.user_id,
|
|
66
|
+
"platformID": resolve_platform_id(account.platform),
|
|
67
|
+
"provider": "magiccampus",
|
|
68
|
+
"transport": "ws_client",
|
|
69
|
+
"connected": True,
|
|
70
|
+
"loginStatus": "connected",
|
|
71
|
+
"wsAddr": credentials.ws_addr if credentials else "",
|
|
72
|
+
"apiAddr": credentials.api_addr if credentials else "",
|
|
73
|
+
"magiccampus": {
|
|
74
|
+
"enabled": account.enabled,
|
|
75
|
+
"platform": account.platform,
|
|
76
|
+
"apiUrl": account.apiurl,
|
|
77
|
+
"apiKeyPreview": mask_secret(account.apikey),
|
|
78
|
+
"expireAt": expire_at,
|
|
79
|
+
"expiresInSeconds": max(0, expire_at - now) if expire_at else 0,
|
|
80
|
+
"dataDir": str(_build_data_dir(account)),
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return with_connected_sdk(account, _probe)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..accounts import get_account_config
|
|
4
|
+
from ..config_store import read_config_root
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def resolve_account_or_throw(root: dict[str, object], account_id: str | None):
|
|
8
|
+
account = get_account_config(root, account_id)
|
|
9
|
+
if account is None:
|
|
10
|
+
resolved = account_id or str(root.get("defaultAccountId") or "default")
|
|
11
|
+
raise RuntimeError(
|
|
12
|
+
f"No usable MagicCampus account found for '{resolved}'. Run 'config init' or set MAGICCAMPUS_* env vars."
|
|
13
|
+
)
|
|
14
|
+
return account
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_auth_command(subcommand: str | None, argv: list[str], parse_options):
|
|
18
|
+
if subcommand != "status":
|
|
19
|
+
raise RuntimeError(f"Unknown auth subcommand: {subcommand}")
|
|
20
|
+
|
|
21
|
+
options, _ = parse_options(argv)
|
|
22
|
+
root = read_config_root()
|
|
23
|
+
account = resolve_account_or_throw(root, options.get("account"))
|
|
24
|
+
from ..client import get_auth_status
|
|
25
|
+
|
|
26
|
+
return get_auth_status(account)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ..accounts import account_config_to_raw, get_account_config, list_account_summaries, remove_stored_account, upsert_stored_account
|
|
4
|
+
from ..config_store import get_config_path, read_config_root, write_config_root
|
|
5
|
+
from ..prompts import prompt_for_account
|
|
6
|
+
from ..utils import env_string, parse_optional_bool, trim_string
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _first_non_empty(*values: object) -> str:
|
|
10
|
+
for value in values:
|
|
11
|
+
text = trim_string(value)
|
|
12
|
+
if text:
|
|
13
|
+
return text
|
|
14
|
+
return ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _has_non_interactive_input(options: dict[str, object]) -> bool:
|
|
18
|
+
return any(
|
|
19
|
+
options.get(key) is not None
|
|
20
|
+
for key in (
|
|
21
|
+
"apiurl",
|
|
22
|
+
"api-url",
|
|
23
|
+
"apikey",
|
|
24
|
+
"api-key",
|
|
25
|
+
"platform",
|
|
26
|
+
"enabled",
|
|
27
|
+
"default",
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_input(existing: dict[str, object], options: dict[str, object], account_id: str) -> dict[str, object]:
|
|
33
|
+
return {
|
|
34
|
+
"apiUrl": _first_non_empty(options.get("apiurl"), options.get("api-url"), existing.get("apiUrl"), env_string("MAGICCAMPUS_APIURL")),
|
|
35
|
+
"apiKey": _first_non_empty(options.get("apikey"), options.get("api-key"), existing.get("apiKey"), env_string("MAGICCAMPUS_APIKEY")),
|
|
36
|
+
"platform": _first_non_empty(options.get("platform"), existing.get("platform"), env_string("MAGICCAMPUS_PLATFORM"), "IOS"),
|
|
37
|
+
"enabled": parse_optional_bool(options.get("enabled"), existing.get("enabled") is not False),
|
|
38
|
+
"makeDefault": parse_optional_bool(options.get("default"), account_id == "default"),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def run_config_command(subcommand: str | None, argv: list[str], parse_options):
|
|
43
|
+
root = read_config_root()
|
|
44
|
+
options, positionals = parse_options(argv)
|
|
45
|
+
|
|
46
|
+
if subcommand == "init":
|
|
47
|
+
account_id = trim_string(options.get("account")) or "default"
|
|
48
|
+
resolved_existing = get_account_config(root, account_id)
|
|
49
|
+
existing = account_config_to_raw(resolved_existing) if resolved_existing is not None else {}
|
|
50
|
+
configured = _build_input(existing, options, account_id)
|
|
51
|
+
prompted = configured if _has_non_interactive_input(options) else prompt_for_account(existing, account_id)
|
|
52
|
+
|
|
53
|
+
if not trim_string(prompted.get("apiUrl")) or not trim_string(prompted.get("apiKey")):
|
|
54
|
+
raise RuntimeError("apiurl and apikey are required.")
|
|
55
|
+
|
|
56
|
+
next_root = upsert_stored_account(root, account_id, prompted, bool(prompted.get("makeDefault")))
|
|
57
|
+
write_config_root(next_root)
|
|
58
|
+
return {
|
|
59
|
+
"ok": True,
|
|
60
|
+
"accountId": account_id,
|
|
61
|
+
"defaultAccountId": next_root["defaultAccountId"],
|
|
62
|
+
"configPath": str(get_config_path()),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if subcommand == "list":
|
|
66
|
+
return list_account_summaries(root)
|
|
67
|
+
|
|
68
|
+
if subcommand == "use":
|
|
69
|
+
account_id = positionals[0] if positionals else ""
|
|
70
|
+
accounts = root.get("accounts")
|
|
71
|
+
if not account_id:
|
|
72
|
+
raise RuntimeError("config use requires <accountId>.")
|
|
73
|
+
if not isinstance(accounts, dict) or account_id not in accounts:
|
|
74
|
+
raise RuntimeError(f"Unknown account: {account_id}")
|
|
75
|
+
next_root = dict(root)
|
|
76
|
+
next_root["defaultAccountId"] = account_id
|
|
77
|
+
write_config_root(next_root)
|
|
78
|
+
return {"ok": True, "defaultAccountId": account_id}
|
|
79
|
+
|
|
80
|
+
if subcommand == "remove":
|
|
81
|
+
account_id = positionals[0] if positionals else ""
|
|
82
|
+
accounts = root.get("accounts")
|
|
83
|
+
if not account_id:
|
|
84
|
+
raise RuntimeError("config remove requires <accountId>.")
|
|
85
|
+
if not isinstance(accounts, dict) or account_id not in accounts:
|
|
86
|
+
raise RuntimeError(f"Unknown account: {account_id}")
|
|
87
|
+
next_root = remove_stored_account(root, account_id)
|
|
88
|
+
write_config_root(next_root)
|
|
89
|
+
return {
|
|
90
|
+
"ok": True,
|
|
91
|
+
"removedAccountId": account_id,
|
|
92
|
+
"defaultAccountId": next_root["defaultAccountId"],
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
raise RuntimeError(f"Unknown config subcommand: {subcommand}")
|