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 +142 -0
- sermo-0.1.0/README.md +122 -0
- sermo-0.1.0/pyproject.toml +37 -0
- sermo-0.1.0/sermo.egg-info/PKG-INFO +142 -0
- sermo-0.1.0/sermo.egg-info/SOURCES.txt +13 -0
- sermo-0.1.0/sermo.egg-info/dependency_links.txt +1 -0
- sermo-0.1.0/sermo.egg-info/entry_points.txt +2 -0
- sermo-0.1.0/sermo.egg-info/top_level.txt +1 -0
- sermo-0.1.0/sermo_cli/__init__.py +4 -0
- sermo-0.1.0/sermo_cli/__main__.py +6 -0
- sermo-0.1.0/sermo_cli/api.py +164 -0
- sermo-0.1.0/sermo_cli/cli.py +260 -0
- sermo-0.1.0/sermo_cli/formatting.py +100 -0
- sermo-0.1.0/sermo_cli/store.py +72 -0
- sermo-0.1.0/setup.cfg +4 -0
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sermo_cli
|
|
@@ -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