sermo 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.
sermo-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: sermo
3
+ Version: 0.1.0
4
+ Summary: Command-line Sermo client
5
+ Author: Sermo
6
+ Project-URL: Homepage, https://sermo.jyonn.space
7
+ Keywords: sermo,chat,cli
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: End Users/Desktop
11
+ Classifier: Natural Language :: Chinese (Simplified)
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Communications :: Chat
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+
21
+ # sermo
22
+
23
+ Sermo 的命令行客户端。它直接接入 `sermo-backend` API,适合在终端里快速查看会话、发送文字消息和检查好友信息。
24
+
25
+ 当前版本先聚焦文字体验:
26
+
27
+ - 支持进入空间并保存登录状态。
28
+ - 支持查看当前账户、聊天列表、好友列表、好友申请。
29
+ - 支持查看聊天记录、发送文字消息、持续刷新新消息。
30
+ - 图片、视频、语音、文件等多媒体消息暂不下载,统一展示为 `[图片]`、`[视频]`、`[语音]`、`[文件]`。
31
+ - 暂不展示头像。
32
+
33
+ ## 安装
34
+
35
+ 开发环境下可以直接运行:
36
+
37
+ ```bash
38
+ python -m sermo_cli --help
39
+ ```
40
+
41
+ 也可以安装为本地命令:
42
+
43
+ ```bash
44
+ pip install -e .
45
+ sermo --help
46
+ ```
47
+
48
+ 从 PyPI 安装:
49
+
50
+ ```bash
51
+ pip install sermo
52
+ sermo --help
53
+ ```
54
+
55
+ ## API 地址
56
+
57
+ 默认 API 地址是:
58
+
59
+ ```text
60
+ https://api.sermo.jyonn.space
61
+ ```
62
+
63
+ 可以用环境变量或参数覆盖:
64
+
65
+ ```bash
66
+ SERMO_API_BASE=http://localhost:8000 python -m sermo_cli chats
67
+ python -m sermo_cli --api http://localhost:8000 chats
68
+ ```
69
+
70
+ ## 常用命令
71
+
72
+ 登录 / 进入空间:
73
+
74
+ ```bash
75
+ sermo login --space yuanmeng --name 软糖大啾咪
76
+ ```
77
+
78
+ 如果昵称需要访问密码,CLI 会自动提示输入密码。
79
+
80
+ 查看当前账户:
81
+
82
+ ```bash
83
+ sermo me
84
+ ```
85
+
86
+ 查看聊天列表:
87
+
88
+ ```bash
89
+ sermo chats
90
+ ```
91
+
92
+ 打开聊天:
93
+
94
+ ```bash
95
+ sermo chat 1
96
+ ```
97
+
98
+ 在聊天界面里:
99
+
100
+ - 直接输入文字并回车即可发送。
101
+ - 输入 `/refresh` 刷新新消息。
102
+ - 输入 `/quit` 退出。
103
+
104
+ 只发送一条消息:
105
+
106
+ ```bash
107
+ sermo send 1 你好
108
+ ```
109
+
110
+ 持续刷新聊天:
111
+
112
+ ```bash
113
+ sermo chat 1 --follow
114
+ ```
115
+
116
+ 查看好友:
117
+
118
+ ```bash
119
+ sermo friends
120
+ ```
121
+
122
+ 查看好友申请:
123
+
124
+ ```bash
125
+ sermo requests
126
+ ```
127
+
128
+ 退出:
129
+
130
+ ```bash
131
+ sermo logout
132
+ ```
133
+
134
+ ## 登录状态
135
+
136
+ 登录状态默认保存到:
137
+
138
+ ```text
139
+ ~/.sermo-cli/config.json
140
+ ```
141
+
142
+ 可以用 `SERMO_CLI_CONFIG` 指定其他路径,便于测试或多账号切换。
sermo-0.1.0/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # sermo
2
+
3
+ Sermo 的命令行客户端。它直接接入 `sermo-backend` API,适合在终端里快速查看会话、发送文字消息和检查好友信息。
4
+
5
+ 当前版本先聚焦文字体验:
6
+
7
+ - 支持进入空间并保存登录状态。
8
+ - 支持查看当前账户、聊天列表、好友列表、好友申请。
9
+ - 支持查看聊天记录、发送文字消息、持续刷新新消息。
10
+ - 图片、视频、语音、文件等多媒体消息暂不下载,统一展示为 `[图片]`、`[视频]`、`[语音]`、`[文件]`。
11
+ - 暂不展示头像。
12
+
13
+ ## 安装
14
+
15
+ 开发环境下可以直接运行:
16
+
17
+ ```bash
18
+ python -m sermo_cli --help
19
+ ```
20
+
21
+ 也可以安装为本地命令:
22
+
23
+ ```bash
24
+ pip install -e .
25
+ sermo --help
26
+ ```
27
+
28
+ 从 PyPI 安装:
29
+
30
+ ```bash
31
+ pip install sermo
32
+ sermo --help
33
+ ```
34
+
35
+ ## API 地址
36
+
37
+ 默认 API 地址是:
38
+
39
+ ```text
40
+ https://api.sermo.jyonn.space
41
+ ```
42
+
43
+ 可以用环境变量或参数覆盖:
44
+
45
+ ```bash
46
+ SERMO_API_BASE=http://localhost:8000 python -m sermo_cli chats
47
+ python -m sermo_cli --api http://localhost:8000 chats
48
+ ```
49
+
50
+ ## 常用命令
51
+
52
+ 登录 / 进入空间:
53
+
54
+ ```bash
55
+ sermo login --space yuanmeng --name 软糖大啾咪
56
+ ```
57
+
58
+ 如果昵称需要访问密码,CLI 会自动提示输入密码。
59
+
60
+ 查看当前账户:
61
+
62
+ ```bash
63
+ sermo me
64
+ ```
65
+
66
+ 查看聊天列表:
67
+
68
+ ```bash
69
+ sermo chats
70
+ ```
71
+
72
+ 打开聊天:
73
+
74
+ ```bash
75
+ sermo chat 1
76
+ ```
77
+
78
+ 在聊天界面里:
79
+
80
+ - 直接输入文字并回车即可发送。
81
+ - 输入 `/refresh` 刷新新消息。
82
+ - 输入 `/quit` 退出。
83
+
84
+ 只发送一条消息:
85
+
86
+ ```bash
87
+ sermo send 1 你好
88
+ ```
89
+
90
+ 持续刷新聊天:
91
+
92
+ ```bash
93
+ sermo chat 1 --follow
94
+ ```
95
+
96
+ 查看好友:
97
+
98
+ ```bash
99
+ sermo friends
100
+ ```
101
+
102
+ 查看好友申请:
103
+
104
+ ```bash
105
+ sermo requests
106
+ ```
107
+
108
+ 退出:
109
+
110
+ ```bash
111
+ sermo logout
112
+ ```
113
+
114
+ ## 登录状态
115
+
116
+ 登录状态默认保存到:
117
+
118
+ ```text
119
+ ~/.sermo-cli/config.json
120
+ ```
121
+
122
+ 可以用 `SERMO_CLI_CONFIG` 指定其他路径,便于测试或多账号切换。
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sermo"
7
+ version = "0.1.0"
8
+ description = "Command-line Sermo client"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = []
12
+ authors = [
13
+ { name = "Sermo" },
14
+ ]
15
+ keywords = ["sermo", "chat", "cli"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: End Users/Desktop",
20
+ "Natural Language :: Chinese (Simplified)",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Communications :: Chat",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://sermo.jyonn.space"
31
+
32
+ [project.scripts]
33
+ sermo = "sermo_cli.cli:main"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["."]
37
+ include = ["sermo_cli*"]
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: sermo
3
+ Version: 0.1.0
4
+ Summary: Command-line Sermo client
5
+ Author: Sermo
6
+ Project-URL: Homepage, https://sermo.jyonn.space
7
+ Keywords: sermo,chat,cli
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: End Users/Desktop
11
+ Classifier: Natural Language :: Chinese (Simplified)
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Communications :: Chat
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+
21
+ # sermo
22
+
23
+ Sermo 的命令行客户端。它直接接入 `sermo-backend` API,适合在终端里快速查看会话、发送文字消息和检查好友信息。
24
+
25
+ 当前版本先聚焦文字体验:
26
+
27
+ - 支持进入空间并保存登录状态。
28
+ - 支持查看当前账户、聊天列表、好友列表、好友申请。
29
+ - 支持查看聊天记录、发送文字消息、持续刷新新消息。
30
+ - 图片、视频、语音、文件等多媒体消息暂不下载,统一展示为 `[图片]`、`[视频]`、`[语音]`、`[文件]`。
31
+ - 暂不展示头像。
32
+
33
+ ## 安装
34
+
35
+ 开发环境下可以直接运行:
36
+
37
+ ```bash
38
+ python -m sermo_cli --help
39
+ ```
40
+
41
+ 也可以安装为本地命令:
42
+
43
+ ```bash
44
+ pip install -e .
45
+ sermo --help
46
+ ```
47
+
48
+ 从 PyPI 安装:
49
+
50
+ ```bash
51
+ pip install sermo
52
+ sermo --help
53
+ ```
54
+
55
+ ## API 地址
56
+
57
+ 默认 API 地址是:
58
+
59
+ ```text
60
+ https://api.sermo.jyonn.space
61
+ ```
62
+
63
+ 可以用环境变量或参数覆盖:
64
+
65
+ ```bash
66
+ SERMO_API_BASE=http://localhost:8000 python -m sermo_cli chats
67
+ python -m sermo_cli --api http://localhost:8000 chats
68
+ ```
69
+
70
+ ## 常用命令
71
+
72
+ 登录 / 进入空间:
73
+
74
+ ```bash
75
+ sermo login --space yuanmeng --name 软糖大啾咪
76
+ ```
77
+
78
+ 如果昵称需要访问密码,CLI 会自动提示输入密码。
79
+
80
+ 查看当前账户:
81
+
82
+ ```bash
83
+ sermo me
84
+ ```
85
+
86
+ 查看聊天列表:
87
+
88
+ ```bash
89
+ sermo chats
90
+ ```
91
+
92
+ 打开聊天:
93
+
94
+ ```bash
95
+ sermo chat 1
96
+ ```
97
+
98
+ 在聊天界面里:
99
+
100
+ - 直接输入文字并回车即可发送。
101
+ - 输入 `/refresh` 刷新新消息。
102
+ - 输入 `/quit` 退出。
103
+
104
+ 只发送一条消息:
105
+
106
+ ```bash
107
+ sermo send 1 你好
108
+ ```
109
+
110
+ 持续刷新聊天:
111
+
112
+ ```bash
113
+ sermo chat 1 --follow
114
+ ```
115
+
116
+ 查看好友:
117
+
118
+ ```bash
119
+ sermo friends
120
+ ```
121
+
122
+ 查看好友申请:
123
+
124
+ ```bash
125
+ sermo requests
126
+ ```
127
+
128
+ 退出:
129
+
130
+ ```bash
131
+ sermo logout
132
+ ```
133
+
134
+ ## 登录状态
135
+
136
+ 登录状态默认保存到:
137
+
138
+ ```text
139
+ ~/.sermo-cli/config.json
140
+ ```
141
+
142
+ 可以用 `SERMO_CLI_CONFIG` 指定其他路径,便于测试或多账号切换。
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ sermo.egg-info/PKG-INFO
4
+ sermo.egg-info/SOURCES.txt
5
+ sermo.egg-info/dependency_links.txt
6
+ sermo.egg-info/entry_points.txt
7
+ sermo.egg-info/top_level.txt
8
+ sermo_cli/__init__.py
9
+ sermo_cli/__main__.py
10
+ sermo_cli/api.py
11
+ sermo_cli/cli.py
12
+ sermo_cli/formatting.py
13
+ sermo_cli/store.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sermo = sermo_cli.cli:main
@@ -0,0 +1 @@
1
+ sermo_cli
@@ -0,0 +1,4 @@
1
+ """Command-line client for Sermo."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+ from urllib import error, parse, request
7
+
8
+ from .store import SessionStore
9
+
10
+
11
+ @dataclass
12
+ class SermoApiError(Exception):
13
+ message: str
14
+ identifier: str = "ERROR"
15
+ status: int = 500
16
+
17
+ def __str__(self) -> str:
18
+ return f"{self.message} ({self.identifier}, HTTP {self.status})"
19
+
20
+
21
+ class ApiClient:
22
+ def __init__(self, store: SessionStore, api_base_url: str):
23
+ self.store = store
24
+ self.api_base_url = api_base_url.rstrip("/")
25
+
26
+ def request(
27
+ self,
28
+ method: str,
29
+ path: str,
30
+ *,
31
+ body: dict[str, Any] | None = None,
32
+ query: dict[str, Any] | None = None,
33
+ auth: bool = False,
34
+ retry_on_unauthorized: bool = True,
35
+ ) -> Any:
36
+ session = self.store.session() if auth else None
37
+ try:
38
+ return self._request_once(method, path, body=body, query=query, token=session.get("access_token") if session else None)
39
+ except SermoApiError as exc:
40
+ if exc.status != 401 or not auth or not retry_on_unauthorized or not session or not session.get("refresh_token"):
41
+ raise
42
+ refreshed = self.refresh(session["refresh_token"])
43
+ return self._request_once(method, path, body=body, query=query, token=refreshed["access_token"])
44
+
45
+ def _request_once(
46
+ self,
47
+ method: str,
48
+ path: str,
49
+ *,
50
+ body: dict[str, Any] | None,
51
+ query: dict[str, Any] | None,
52
+ token: str | None,
53
+ ) -> Any:
54
+ url = f"{self.api_base_url}{self._with_query(path, query)}"
55
+ payload = None if body is None else json.dumps(body).encode("utf-8")
56
+ headers = {
57
+ "Accept": "application/json",
58
+ "User-Agent": "sermo-cli/0.1",
59
+ }
60
+ if payload is not None:
61
+ headers["Content-Type"] = "application/json"
62
+ if token:
63
+ headers["Authorization"] = f"Bearer {token}"
64
+
65
+ req = request.Request(url, data=payload, headers=headers, method=method.upper())
66
+ try:
67
+ with request.urlopen(req, timeout=20) as response:
68
+ text = response.read().decode("utf-8")
69
+ return self._parse_response(text, response.status)
70
+ except error.HTTPError as exc:
71
+ text = exc.read().decode("utf-8", errors="replace")
72
+ return self._parse_response(text, exc.code)
73
+ except error.URLError as exc:
74
+ raise SermoApiError(f"无法连接 Sermo API:{exc.reason}", "NETWORK_ERROR", 0) from exc
75
+
76
+ def _parse_response(self, text: str, status: int) -> Any:
77
+ if not text:
78
+ if 200 <= status < 300:
79
+ return None
80
+ raise SermoApiError(f"HTTP {status}", "HTTP_ERROR", status)
81
+ try:
82
+ payload = json.loads(text)
83
+ except json.JSONDecodeError as exc:
84
+ if 200 <= status < 300:
85
+ return text
86
+ raise SermoApiError(f"HTTP {status}", "HTTP_ERROR", status) from exc
87
+
88
+ identifier = payload.get("identifier", "ERROR")
89
+ if not (200 <= status < 300) or identifier != "OK":
90
+ message = payload.get("user_message") or payload.get("message") or "请求失败"
91
+ raise SermoApiError(message, identifier, status)
92
+ return payload.get("body")
93
+
94
+ @staticmethod
95
+ def _with_query(path: str, query: dict[str, Any] | None) -> str:
96
+ if not query:
97
+ return path
98
+ filtered = {key: value for key, value in query.items() if value is not None and value != ""}
99
+ if not filtered:
100
+ return path
101
+ return f"{path}?{parse.urlencode(filtered)}"
102
+
103
+ def refresh(self, refresh_token: str) -> dict[str, Any]:
104
+ body = self.request(
105
+ "POST",
106
+ "/users/refresh",
107
+ body={"refresh": refresh_token},
108
+ retry_on_unauthorized=False,
109
+ )
110
+ session = {
111
+ "access_token": body["auth"],
112
+ "refresh_token": body["refresh"],
113
+ "user": body["data"],
114
+ "space": self.store.session().get("space") if self.store.session() else None,
115
+ }
116
+ self.store.set_session(session, self.api_base_url)
117
+ return session
118
+
119
+ def join(self, slug: str, name: str, password: str | None, language: str) -> dict[str, Any]:
120
+ return self.request(
121
+ "POST",
122
+ "/spaces/join",
123
+ body={"slug": slug, "name": name, "password": password or None, "language": language},
124
+ )
125
+
126
+ def logout(self) -> None:
127
+ session = self.store.session()
128
+ refresh = session.get("refresh_token") if session else None
129
+ if refresh:
130
+ try:
131
+ self.request("POST", "/users/logout", body={"refresh": refresh}, retry_on_unauthorized=False)
132
+ except SermoApiError:
133
+ pass
134
+ self.store.clear()
135
+
136
+ def me(self) -> dict[str, Any]:
137
+ return self.request("GET", "/users/me", auth=True)
138
+
139
+ def space_me(self) -> dict[str, Any]:
140
+ return self.request("GET", "/spaces/me", auth=True)
141
+
142
+ def chats(self) -> list[dict[str, Any]]:
143
+ return self.request("GET", "/chats/", auth=True)
144
+
145
+ def messages(self, chat_id: int, limit: int = 30, before: int | None = None, after: int | None = None) -> list[dict[str, Any]]:
146
+ return self.request(
147
+ "GET",
148
+ "/messages/",
149
+ auth=True,
150
+ query={"chat_id": chat_id, "limit": limit, "before": before, "after": after},
151
+ )
152
+
153
+ def send_text(self, chat_id: int, text: str) -> dict[str, Any]:
154
+ return self.request("POST", "/messages/", auth=True, query={"chat_id": chat_id}, body={"type": 0, "content": text})
155
+
156
+ def mark_read(self, chat_id: int) -> None:
157
+ self.request("POST", "/chats/read", auth=True, query={"chat_id": chat_id})
158
+
159
+ def friends(self) -> list[dict[str, Any]]:
160
+ return self.request("GET", "/friends/", auth=True)
161
+
162
+ def friend_requests(self) -> dict[str, list[dict[str, Any]]]:
163
+ return self.request("GET", "/friends/requests", auth=True)
164
+
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import getpass
5
+ import sys
6
+ import time
7
+ from typing import Any
8
+
9
+ from .api import ApiClient, SermoApiError
10
+ from .formatting import chat_title, format_time, message_text, print_header, style, user_name
11
+ from .store import SessionStore
12
+
13
+
14
+ def build_client(args: argparse.Namespace) -> ApiClient:
15
+ store = SessionStore()
16
+ return ApiClient(store, store.api_base_url(getattr(args, "api", None)))
17
+
18
+
19
+ def require_session(client: ApiClient) -> dict[str, Any]:
20
+ session = client.store.session()
21
+ if not session:
22
+ raise SermoApiError("还没有登录。请先运行 sermo login。", "NOT_LOGGED_IN", 0)
23
+ return session
24
+
25
+
26
+ def prompt_if_missing(value: str | None, label: str) -> str:
27
+ if value:
28
+ return value
29
+ while True:
30
+ entered = input(f"{label}: ").strip()
31
+ if entered:
32
+ return entered
33
+
34
+
35
+ def command_login(args: argparse.Namespace) -> int:
36
+ client = build_client(args)
37
+ slug = prompt_if_missing(args.space, "空间 slug")
38
+ name = prompt_if_missing(args.name, "昵称")
39
+ password = args.password
40
+
41
+ try:
42
+ payload = client.join(slug, name, password, args.language)
43
+ except SermoApiError as exc:
44
+ if exc.identifier == "USER@PASSWORD_REQUIRED":
45
+ password = getpass.getpass("这个昵称需要访问密码: ")
46
+ payload = client.join(slug, name, password, args.language)
47
+ else:
48
+ raise
49
+
50
+ session = {
51
+ "access_token": payload["auth"]["auth"],
52
+ "refresh_token": payload["auth"]["refresh"],
53
+ "user": payload["auth"]["data"],
54
+ "space": payload["space"],
55
+ }
56
+ client.store.set_session(session, client.api_base_url)
57
+ print(style.green("已进入空间"))
58
+ print(f"{payload['space']['name']} @{payload['space']['slug']} · {payload['auth']['data']['name']}")
59
+ return 0
60
+
61
+
62
+ def command_logout(args: argparse.Namespace) -> int:
63
+ client = build_client(args)
64
+ client.logout()
65
+ print("已退出登录。")
66
+ return 0
67
+
68
+
69
+ def command_me(args: argparse.Namespace) -> int:
70
+ client = build_client(args)
71
+ require_session(client)
72
+ me = client.me()
73
+ space = client.space_me()
74
+ print_header(f"{me.get('name')} @ {space.get('slug')}", space.get("name"))
75
+ print(f"用户 ID: {me.get('user_id')}")
76
+ print(f"身份: {'Official' if me.get('official') else 'Member'}")
77
+ print(f"认证: {'已认证' if me.get('verified') else '未认证'}")
78
+ print(f"密码: {'已设置' if me.get('has_password') else '未设置'}")
79
+ return 0
80
+
81
+
82
+ def command_chats(args: argparse.Namespace) -> int:
83
+ client = build_client(args)
84
+ session = require_session(client)
85
+ chats = client.chats()
86
+ self_user_id = session.get("user", {}).get("user_id")
87
+ if not chats:
88
+ print("暂无会话。")
89
+ return 0
90
+ print_header("聊天")
91
+ for chat in chats:
92
+ last = chat.get("last_message")
93
+ preview = message_text(last) if last else "暂无消息"
94
+ unread = chat.get("unread_count") or 0
95
+ badge = style.green(f" +{unread}") if unread else ""
96
+ print(f"{style.cyan(str(chat['chat_id']).rjust(4))} {chat_title(chat, self_user_id)}{badge}")
97
+ print(f" {style.muted(format_time(chat.get('last_chat_at')))} {preview}")
98
+ return 0
99
+
100
+
101
+ def render_messages(messages: list[dict[str, Any]], self_user_id: int | None) -> None:
102
+ for message in sorted(messages, key=lambda item: item.get("message_id") or 0):
103
+ author = "我" if message.get("user", {}).get("user_id") == self_user_id else user_name(message.get("user"))
104
+ text = message_text(message)
105
+ timestamp = format_time(message.get("created_at"), full=True)
106
+ print(f"{style.muted(timestamp)} {style.bold(author)}: {text}")
107
+
108
+
109
+ def command_chat(args: argparse.Namespace) -> int:
110
+ client = build_client(args)
111
+ session = require_session(client)
112
+ self_user_id = session.get("user", {}).get("user_id")
113
+ messages = client.messages(args.chat_id, limit=args.limit)
114
+ render_messages(messages, self_user_id)
115
+ if not args.no_mark_read:
116
+ client.mark_read(args.chat_id)
117
+
118
+ if args.follow:
119
+ last_seen = max((item.get("message_id") or 0 for item in messages), default=0)
120
+ print(style.muted("进入实时刷新模式,按 Ctrl+C 退出。"))
121
+ try:
122
+ while True:
123
+ time.sleep(args.interval)
124
+ newer = client.messages(args.chat_id, limit=50, after=last_seen)
125
+ if newer:
126
+ render_messages(newer, self_user_id)
127
+ last_seen = max(item.get("message_id") or last_seen for item in newer)
128
+ if not args.no_mark_read:
129
+ client.mark_read(args.chat_id)
130
+ except KeyboardInterrupt:
131
+ print()
132
+ return 0
133
+
134
+ if not sys.stdin.isatty():
135
+ return 0
136
+
137
+ print(style.muted("输入消息并回车发送;/refresh 刷新;/quit 退出。"))
138
+ last_seen = max((item.get("message_id") or 0 for item in messages), default=0)
139
+ while True:
140
+ try:
141
+ text = input("> ").strip()
142
+ except (EOFError, KeyboardInterrupt):
143
+ print()
144
+ return 0
145
+ if not text:
146
+ continue
147
+ if text in {"/q", "/quit", "退出"}:
148
+ return 0
149
+ if text == "/refresh":
150
+ newer = client.messages(args.chat_id, limit=50, after=last_seen)
151
+ render_messages(newer, self_user_id)
152
+ if newer:
153
+ last_seen = max(item.get("message_id") or last_seen for item in newer)
154
+ continue
155
+ message = client.send_text(args.chat_id, text)
156
+ render_messages([message], self_user_id)
157
+ last_seen = max(last_seen, message.get("message_id") or last_seen)
158
+
159
+
160
+ def command_send(args: argparse.Namespace) -> int:
161
+ client = build_client(args)
162
+ session = require_session(client)
163
+ text = " ".join(args.text).strip()
164
+ if not text:
165
+ text = prompt_if_missing(None, "消息")
166
+ message = client.send_text(args.chat_id, text)
167
+ render_messages([message], session.get("user", {}).get("user_id"))
168
+ return 0
169
+
170
+
171
+ def command_friends(args: argparse.Namespace) -> int:
172
+ client = build_client(args)
173
+ require_session(client)
174
+ friends = client.friends()
175
+ if not friends:
176
+ print("暂无好友。")
177
+ return 0
178
+ print_header("好友")
179
+ for friend in friends:
180
+ online = style.green("在线") if friend.get("is_alive") else style.muted("离线")
181
+ print(f"{str(friend.get('user_id')).rjust(4)} {user_name(friend)} {online}")
182
+ return 0
183
+
184
+
185
+ def command_requests(args: argparse.Namespace) -> int:
186
+ client = build_client(args)
187
+ require_session(client)
188
+ payload = client.friend_requests()
189
+ incoming = payload.get("incoming") or []
190
+ outgoing = payload.get("outgoing") or []
191
+ print_header("好友申请")
192
+ print(style.bold("收到的"))
193
+ if incoming:
194
+ for item in incoming:
195
+ print(f"{item.get('request_id')}: {user_name(item.get('from_user'))} -> 我")
196
+ else:
197
+ print(style.muted("暂无"))
198
+ print(style.bold("发出的"))
199
+ if outgoing:
200
+ for item in outgoing:
201
+ print(f"{item.get('request_id')}: 我 -> {user_name(item.get('to_user'))}")
202
+ else:
203
+ print(style.muted("暂无"))
204
+ return 0
205
+
206
+
207
+ def build_parser() -> argparse.ArgumentParser:
208
+ parser = argparse.ArgumentParser(prog="sermo", description="Sermo 命令行客户端")
209
+ parser.add_argument("--api", help="Sermo API 地址,默认读取 SERMO_API_BASE 或 https://api.sermo.jyonn.space")
210
+ subparsers = parser.add_subparsers(dest="command", required=True)
211
+
212
+ login = subparsers.add_parser("login", help="进入空间 / 登录昵称")
213
+ login.add_argument("-s", "--space", help="空间 slug")
214
+ login.add_argument("-n", "--name", help="昵称")
215
+ login.add_argument("-p", "--password", help="访问密码")
216
+ login.add_argument("--language", default="zh-CN", choices=["zh-CN", "en"], help="偏好语言")
217
+ login.set_defaults(func=command_login)
218
+
219
+ logout = subparsers.add_parser("logout", help="退出登录")
220
+ logout.set_defaults(func=command_logout)
221
+
222
+ me = subparsers.add_parser("me", help="查看当前账户")
223
+ me.set_defaults(func=command_me)
224
+
225
+ chats = subparsers.add_parser("chats", help="查看聊天列表")
226
+ chats.set_defaults(func=command_chats)
227
+
228
+ chat = subparsers.add_parser("chat", help="打开聊天并发送文字")
229
+ chat.add_argument("chat_id", type=int)
230
+ chat.add_argument("-n", "--limit", type=int, default=30)
231
+ chat.add_argument("--follow", action="store_true", help="持续刷新新消息")
232
+ chat.add_argument("--interval", type=float, default=5.0, help="follow 刷新间隔秒数")
233
+ chat.add_argument("--no-mark-read", action="store_true", help="不标记已读")
234
+ chat.set_defaults(func=command_chat)
235
+
236
+ send = subparsers.add_parser("send", help="发送一条文字消息")
237
+ send.add_argument("chat_id", type=int)
238
+ send.add_argument("text", nargs="*")
239
+ send.set_defaults(func=command_send)
240
+
241
+ friends = subparsers.add_parser("friends", help="查看好友")
242
+ friends.set_defaults(func=command_friends)
243
+
244
+ requests = subparsers.add_parser("requests", help="查看好友申请")
245
+ requests.set_defaults(func=command_requests)
246
+ return parser
247
+
248
+
249
+ def main(argv: list[str] | None = None) -> int:
250
+ parser = build_parser()
251
+ args = parser.parse_args(argv)
252
+ try:
253
+ return args.func(args)
254
+ except SermoApiError as exc:
255
+ print(style.red(str(exc)), file=sys.stderr)
256
+ return 1
257
+ except KeyboardInterrupt:
258
+ print()
259
+ return 130
260
+
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+
8
+ MESSAGE_PLACEHOLDERS = {
9
+ "image": "[图片]",
10
+ "video": "[视频]",
11
+ "audio": "[语音]",
12
+ "file": "[文件]",
13
+ "system": "[系统消息]",
14
+ }
15
+
16
+ TYPE_PLACEHOLDERS = {
17
+ 1: "[图片]",
18
+ 2: "[文件]",
19
+ 3: "[系统消息]",
20
+ 4: "[视频]",
21
+ 5: "[语音]",
22
+ }
23
+
24
+
25
+ class Style:
26
+ def __init__(self):
27
+ self.enabled = sys.stdout.isatty()
28
+
29
+ def wrap(self, text: str, code: str) -> str:
30
+ if not self.enabled:
31
+ return text
32
+ return f"\033[{code}m{text}\033[0m"
33
+
34
+ def bold(self, text: str) -> str:
35
+ return self.wrap(text, "1")
36
+
37
+ def muted(self, text: str) -> str:
38
+ return self.wrap(text, "90")
39
+
40
+ def green(self, text: str) -> str:
41
+ return self.wrap(text, "32")
42
+
43
+ def cyan(self, text: str) -> str:
44
+ return self.wrap(text, "36")
45
+
46
+ def red(self, text: str) -> str:
47
+ return self.wrap(text, "31")
48
+
49
+
50
+ style = Style()
51
+
52
+
53
+ def format_time(value: float | int | None, full: bool = False) -> str:
54
+ if not value:
55
+ return "-"
56
+ fmt = "%Y-%m-%d %H:%M" if full else "%m-%d %H:%M"
57
+ return datetime.fromtimestamp(float(value)).strftime(fmt)
58
+
59
+
60
+ def user_name(user: dict[str, Any] | None) -> str:
61
+ if not user:
62
+ return "未知用户"
63
+ return str(user.get("name") or f"User#{user.get('user_id', '?')}")
64
+
65
+
66
+ def chat_title(chat: dict[str, Any], self_user_id: int | None = None) -> str:
67
+ if chat.get("group"):
68
+ title = chat.get("title") or "群聊"
69
+ count = len(chat.get("members") or [])
70
+ return f"{title} ({count}人)"
71
+ owner = chat.get("owner")
72
+ if owner:
73
+ return user_name(owner)
74
+ members = chat.get("members") or []
75
+ for member in members:
76
+ if member.get("user_id") != self_user_id:
77
+ return user_name(member)
78
+ return chat.get("title") or "私聊"
79
+
80
+
81
+ def message_text(message: dict[str, Any]) -> str:
82
+ payload = message.get("payload") or {}
83
+ kind = payload.get("kind")
84
+ if kind == "text":
85
+ return str(payload.get("text") or message.get("content") or "")
86
+ if kind in MESSAGE_PLACEHOLDERS:
87
+ if kind == "audio" and payload.get("duration_seconds"):
88
+ return f"[语音 {int(payload['duration_seconds'])}s]"
89
+ return MESSAGE_PLACEHOLDERS[kind]
90
+ msg_type = message.get("type")
91
+ if msg_type in TYPE_PLACEHOLDERS:
92
+ return TYPE_PLACEHOLDERS[msg_type]
93
+ return str(message.get("content") or "")
94
+
95
+
96
+ def print_header(title: str, subtitle: str | None = None) -> None:
97
+ print(style.bold(title))
98
+ if subtitle:
99
+ print(style.muted(subtitle))
100
+
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ DEFAULT_API_BASE_URL = "https://api.sermo.jyonn.space"
10
+ CONFIG_ENV = "SERMO_CLI_CONFIG"
11
+ API_ENV = "SERMO_API_BASE"
12
+
13
+
14
+ class SessionStore:
15
+ def __init__(self, path: Path | None = None):
16
+ self.path = path or self.default_path()
17
+ self._data: dict[str, Any] | None = None
18
+
19
+ @staticmethod
20
+ def default_path() -> Path:
21
+ configured = os.environ.get(CONFIG_ENV)
22
+ if configured:
23
+ return Path(configured).expanduser()
24
+ return Path.home() / ".sermo-cli" / "config.json"
25
+
26
+ def load(self) -> dict[str, Any]:
27
+ if self._data is not None:
28
+ return self._data
29
+ if not self.path.exists():
30
+ self._data = {}
31
+ return self._data
32
+ try:
33
+ self._data = json.loads(self.path.read_text(encoding="utf-8"))
34
+ except (OSError, json.JSONDecodeError):
35
+ self._data = {}
36
+ return self._data
37
+
38
+ def save(self, data: dict[str, Any]) -> None:
39
+ self.path.parent.mkdir(parents=True, exist_ok=True)
40
+ self.path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
41
+ try:
42
+ self.path.chmod(0o600)
43
+ except OSError:
44
+ pass
45
+ self._data = data
46
+
47
+ def clear(self) -> None:
48
+ self._data = {}
49
+ if self.path.exists():
50
+ self.path.unlink()
51
+
52
+ def api_base_url(self, override: str | None = None) -> str:
53
+ raw = override or os.environ.get(API_ENV) or self.load().get("api_base_url") or DEFAULT_API_BASE_URL
54
+ return str(raw).rstrip("/")
55
+
56
+ def session(self) -> dict[str, Any] | None:
57
+ session = self.load().get("session")
58
+ return session if isinstance(session, dict) else None
59
+
60
+ def set_session(self, session: dict[str, Any], api_base_url: str) -> None:
61
+ data = self.load().copy()
62
+ data["api_base_url"] = api_base_url.rstrip("/")
63
+ data["session"] = session
64
+ self.save(data)
65
+
66
+ def update_session(self, patch: dict[str, Any]) -> None:
67
+ data = self.load().copy()
68
+ current = data.get("session") if isinstance(data.get("session"), dict) else {}
69
+ current.update(patch)
70
+ data["session"] = current
71
+ self.save(data)
72
+
sermo-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+