voice-input 1.0.0__py3-none-any.whl
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.
- voice_input/__init__.py +4 -0
- voice_input/__main__.py +5 -0
- voice_input/cli.py +158 -0
- voice_input/config.py +116 -0
- voice_input/server.py +341 -0
- voice_input/templates/index.html +570 -0
- voice_input/utils.py +55 -0
- voice_input-1.0.0.dist-info/METADATA +294 -0
- voice_input-1.0.0.dist-info/RECORD +12 -0
- voice_input-1.0.0.dist-info/WHEEL +5 -0
- voice_input-1.0.0.dist-info/entry_points.txt +2 -0
- voice_input-1.0.0.dist-info/top_level.txt +1 -0
voice_input/__init__.py
ADDED
voice_input/__main__.py
ADDED
voice_input/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""命令行入口 - 支持参数与配置文件"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .config import build_config
|
|
10
|
+
from .utils import get_local_ip
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_args(argv=None):
|
|
14
|
+
p = argparse.ArgumentParser(
|
|
15
|
+
prog="voice-input",
|
|
16
|
+
description="跨设备语音输入传输系统 - 将手机端语音识别文本传送到电脑",
|
|
17
|
+
)
|
|
18
|
+
p.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
19
|
+
p.add_argument("-c", "--config", metavar="FILE", help="YAML 配置文件路径")
|
|
20
|
+
|
|
21
|
+
# 网络
|
|
22
|
+
net = p.add_argument_group("网络")
|
|
23
|
+
net.add_argument("-H", "--host", metavar="ADDR", help="监听地址 (默认 0.0.0.0)")
|
|
24
|
+
net.add_argument("-p", "--port", type=int, metavar="PORT", help="监听端口 (默认 8080)")
|
|
25
|
+
net.add_argument(
|
|
26
|
+
"--allowed-ips",
|
|
27
|
+
metavar="CIDR",
|
|
28
|
+
help="IP 白名单,逗号分隔 (如 192.168.0.0/16,10.0.0.0/8)",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# 安全
|
|
32
|
+
sec = p.add_argument_group("安全")
|
|
33
|
+
sec.add_argument("-t", "--token", metavar="TOKEN", help="鉴权 Token")
|
|
34
|
+
sec.add_argument(
|
|
35
|
+
"--require-token",
|
|
36
|
+
action="store_true",
|
|
37
|
+
default=None,
|
|
38
|
+
help="强制启用 Token 鉴权 (未设 --token 时自动生成)",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# 行为
|
|
42
|
+
beh = p.add_argument_group("行为")
|
|
43
|
+
beh.add_argument(
|
|
44
|
+
"--no-auto-paste",
|
|
45
|
+
action="store_true",
|
|
46
|
+
default=False,
|
|
47
|
+
help="默认不自动粘贴,仅复制到剪贴板",
|
|
48
|
+
)
|
|
49
|
+
beh.add_argument("--history-size", type=int, metavar="N", help="历史记录条数 (默认 50)")
|
|
50
|
+
|
|
51
|
+
# 生产
|
|
52
|
+
prod = p.add_argument_group("生产部署")
|
|
53
|
+
prod.add_argument(
|
|
54
|
+
"--production",
|
|
55
|
+
action="store_true",
|
|
56
|
+
default=False,
|
|
57
|
+
help="使用 waitress 作为生产 WSGI 服务器",
|
|
58
|
+
)
|
|
59
|
+
prod.add_argument("--workers", type=int, metavar="N", help="WSGI 工作线程数 (默认 4)")
|
|
60
|
+
prod.add_argument(
|
|
61
|
+
"--log-level",
|
|
62
|
+
choices=["debug", "info", "warning", "error"],
|
|
63
|
+
help="日志级别 (默认 info)",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return p.parse_args(argv)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv=None):
|
|
70
|
+
args = parse_args(argv)
|
|
71
|
+
|
|
72
|
+
# 将 argparse 结果转为 config dict(None 表示未指定)
|
|
73
|
+
cli_dict = {}
|
|
74
|
+
if args.host is not None:
|
|
75
|
+
cli_dict["host"] = args.host
|
|
76
|
+
if args.port is not None:
|
|
77
|
+
cli_dict["port"] = args.port
|
|
78
|
+
if args.allowed_ips is not None:
|
|
79
|
+
cli_dict["allowed_ips"] = [s.strip() for s in args.allowed_ips.split(",") if s.strip()]
|
|
80
|
+
if args.token is not None:
|
|
81
|
+
cli_dict["token"] = args.token
|
|
82
|
+
if args.require_token is True:
|
|
83
|
+
cli_dict["require_token"] = True
|
|
84
|
+
if args.no_auto_paste:
|
|
85
|
+
cli_dict["auto_paste"] = False
|
|
86
|
+
if args.history_size is not None:
|
|
87
|
+
cli_dict["history_size"] = args.history_size
|
|
88
|
+
if args.workers is not None:
|
|
89
|
+
cli_dict["workers"] = args.workers
|
|
90
|
+
if args.log_level is not None:
|
|
91
|
+
cli_dict["log_level"] = args.log_level
|
|
92
|
+
|
|
93
|
+
# 构建配置
|
|
94
|
+
cfg = build_config(cli_args=cli_dict, config_file=args.config)
|
|
95
|
+
|
|
96
|
+
# 配置日志
|
|
97
|
+
logging.basicConfig(
|
|
98
|
+
level=getattr(logging, cfg.log_level.upper(), logging.INFO),
|
|
99
|
+
format="%(asctime)s - %(levelname)s - %(message)s",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# 创建应用
|
|
103
|
+
from .server import create_app
|
|
104
|
+
|
|
105
|
+
app = create_app(cfg)
|
|
106
|
+
|
|
107
|
+
# 打印启动信息
|
|
108
|
+
local_ip = get_local_ip()
|
|
109
|
+
banner = f"""
|
|
110
|
+
{'=' * 60}
|
|
111
|
+
跨设备语音输入传输系统 v{__version__}
|
|
112
|
+
{'=' * 60}
|
|
113
|
+
服务地址: http://{local_ip}:{cfg.port}
|
|
114
|
+
手机页面: http://{local_ip}:{cfg.port}/
|
|
115
|
+
状态检查: http://{local_ip}:{cfg.port}/status
|
|
116
|
+
API 接口: http://{local_ip}:{cfg.port}/input (POST)
|
|
117
|
+
历史记录: http://{local_ip}:{cfg.port}/history"""
|
|
118
|
+
|
|
119
|
+
if cfg.token:
|
|
120
|
+
banner += f"\n Token: {cfg.token}"
|
|
121
|
+
else:
|
|
122
|
+
banner += "\n Token: 未启用"
|
|
123
|
+
|
|
124
|
+
is_windows = platform.system() == "Windows"
|
|
125
|
+
banner += f"""
|
|
126
|
+
{'=' * 60}
|
|
127
|
+
提示: 手机和电脑需在同一局域网"""
|
|
128
|
+
if not is_windows:
|
|
129
|
+
banner += "\n 提示: 自动粘贴功能需要 root/sudo 权限 (Linux)"
|
|
130
|
+
banner += f"\n{'=' * 60}"
|
|
131
|
+
print(banner)
|
|
132
|
+
|
|
133
|
+
# 启动服务器
|
|
134
|
+
if args.production:
|
|
135
|
+
_run_production(app, cfg)
|
|
136
|
+
else:
|
|
137
|
+
app.run(host=cfg.host, port=cfg.port, debug=False)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _run_production(app, cfg):
|
|
141
|
+
"""使用 waitress 启动生产服务器"""
|
|
142
|
+
try:
|
|
143
|
+
from waitress import serve
|
|
144
|
+
|
|
145
|
+
workers = cfg.workers if cfg.workers > 1 else 4
|
|
146
|
+
print(f" [waitress] threads={workers}")
|
|
147
|
+
serve(app, host=cfg.host, port=cfg.port, threads=workers)
|
|
148
|
+
except ImportError:
|
|
149
|
+
print(
|
|
150
|
+
"waitress 未安装,回退到 Flask 开发服务器。\n"
|
|
151
|
+
"生产环境请执行: pip install waitress",
|
|
152
|
+
file=sys.stderr,
|
|
153
|
+
)
|
|
154
|
+
app.run(host=cfg.host, port=cfg.port, debug=False)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
main()
|
voice_input/config.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""配置管理 - 支持 YAML 配置文件、环境变量、CLI 参数三级合并"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class AppConfig:
|
|
11
|
+
"""应用配置,优先级:CLI 参数 > 环境变量 > 配置文件 > 默认值"""
|
|
12
|
+
|
|
13
|
+
# 网络
|
|
14
|
+
host: str = "0.0.0.0"
|
|
15
|
+
port: int = 8080
|
|
16
|
+
|
|
17
|
+
# 安全
|
|
18
|
+
allowed_ips: List[str] = field(
|
|
19
|
+
default_factory=lambda: ["192.168.0.0/16", "10.0.0.0/8", "172.16.0.0/12"]
|
|
20
|
+
)
|
|
21
|
+
token: str = ""
|
|
22
|
+
require_token: bool = False
|
|
23
|
+
|
|
24
|
+
# 行为
|
|
25
|
+
auto_paste: bool = True
|
|
26
|
+
history_size: int = 50
|
|
27
|
+
max_content_length: int = 10 * 1024 # 10KB
|
|
28
|
+
|
|
29
|
+
# 生产部署
|
|
30
|
+
workers: int = 1
|
|
31
|
+
log_level: str = "info"
|
|
32
|
+
|
|
33
|
+
def __post_init__(self):
|
|
34
|
+
if self.require_token and not self.token:
|
|
35
|
+
self.token = secrets.token_urlsafe(18)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_bool(val: str) -> bool:
|
|
39
|
+
return val.strip().lower() in {"1", "true", "yes", "on"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_from_yaml(path: str) -> dict:
|
|
43
|
+
"""从 YAML 文件加载配置"""
|
|
44
|
+
try:
|
|
45
|
+
import yaml
|
|
46
|
+
except ImportError:
|
|
47
|
+
raise ImportError(
|
|
48
|
+
"需要 PyYAML 来读取配置文件,请执行: pip install pyyaml"
|
|
49
|
+
)
|
|
50
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
51
|
+
data = yaml.safe_load(f) or {}
|
|
52
|
+
return data
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_from_env() -> dict:
|
|
56
|
+
"""从环境变量加载配置(VOICE_INPUT_ 前缀)"""
|
|
57
|
+
mapping = {
|
|
58
|
+
"VOICE_INPUT_HOST": "host",
|
|
59
|
+
"VOICE_INPUT_PORT": "port",
|
|
60
|
+
"VOICE_INPUT_ALLOWED_IPS": "allowed_ips",
|
|
61
|
+
"VOICE_INPUT_TOKEN": "token",
|
|
62
|
+
"VOICE_INPUT_REQUIRE_TOKEN": "require_token",
|
|
63
|
+
"VOICE_INPUT_AUTO_PASTE": "auto_paste",
|
|
64
|
+
"VOICE_INPUT_HISTORY_SIZE": "history_size",
|
|
65
|
+
"VOICE_INPUT_MAX_CONTENT_LENGTH": "max_content_length",
|
|
66
|
+
"VOICE_INPUT_WORKERS": "workers",
|
|
67
|
+
"VOICE_INPUT_LOG_LEVEL": "log_level",
|
|
68
|
+
}
|
|
69
|
+
result = {}
|
|
70
|
+
for env_key, cfg_key in mapping.items():
|
|
71
|
+
val = os.environ.get(env_key)
|
|
72
|
+
if val is None:
|
|
73
|
+
continue
|
|
74
|
+
result[cfg_key] = val
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _coerce(key: str, val):
|
|
79
|
+
"""将字符串值转为目标类型"""
|
|
80
|
+
bool_keys = {"require_token", "auto_paste"}
|
|
81
|
+
int_keys = {"port", "history_size", "max_content_length", "workers"}
|
|
82
|
+
list_keys = {"allowed_ips"}
|
|
83
|
+
|
|
84
|
+
if key in bool_keys and isinstance(val, str):
|
|
85
|
+
return _parse_bool(val)
|
|
86
|
+
if key in int_keys and isinstance(val, str):
|
|
87
|
+
return int(val)
|
|
88
|
+
if key in list_keys and isinstance(val, str):
|
|
89
|
+
return [s.strip() for s in val.split(",") if s.strip()]
|
|
90
|
+
return val
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def build_config(
|
|
94
|
+
cli_args: Optional[dict] = None,
|
|
95
|
+
config_file: Optional[str] = None,
|
|
96
|
+
) -> AppConfig:
|
|
97
|
+
"""三级合并构建最终配置"""
|
|
98
|
+
merged: dict = {}
|
|
99
|
+
|
|
100
|
+
# 1. 配置文件
|
|
101
|
+
if config_file:
|
|
102
|
+
merged.update(load_from_yaml(config_file))
|
|
103
|
+
|
|
104
|
+
# 2. 环境变量覆盖
|
|
105
|
+
merged.update(load_from_env())
|
|
106
|
+
|
|
107
|
+
# 3. CLI 参数覆盖(过滤 None 值)
|
|
108
|
+
if cli_args:
|
|
109
|
+
for k, v in cli_args.items():
|
|
110
|
+
if v is not None:
|
|
111
|
+
merged[k] = v
|
|
112
|
+
|
|
113
|
+
# 类型转换
|
|
114
|
+
coerced = {k: _coerce(k, v) for k, v in merged.items()}
|
|
115
|
+
|
|
116
|
+
return AppConfig(**coerced)
|
voice_input/server.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Flask 应用与路由定义"""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import csv
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import time
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
from collections import deque
|
|
12
|
+
|
|
13
|
+
from flask import Flask, request, jsonify, render_template, Response
|
|
14
|
+
|
|
15
|
+
from .config import AppConfig
|
|
16
|
+
from .utils import get_local_ip, get_client_ip, is_ip_allowed, is_token_valid
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_PASTE_DELAY = 0.08 # 剪贴板写入后等待时间(秒),确保 X11 剪贴板同步完成
|
|
20
|
+
_RESTORE_DELAY = 0.15 # 粘贴操作后等待时间(秒),确保目标程序完成读取剪贴板
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _do_keyboard_action(action: str, text: str):
|
|
24
|
+
"""执行键盘操作(跨平台),在 copy 之后调用"""
|
|
25
|
+
try:
|
|
26
|
+
import keyboard
|
|
27
|
+
except ImportError:
|
|
28
|
+
logging.warning("keyboard 模块未安装,跳过自动粘贴(文本已复制到剪贴板)")
|
|
29
|
+
return
|
|
30
|
+
except Exception as e:
|
|
31
|
+
logging.warning(f"keyboard 模块加载失败: {e}")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
time.sleep(_PASTE_DELAY)
|
|
36
|
+
is_windows = platform.system() == "Windows"
|
|
37
|
+
if action == "paste":
|
|
38
|
+
keyboard.press_and_release("ctrl+v")
|
|
39
|
+
logging.info("已执行粘贴操作")
|
|
40
|
+
elif action == "paste_terminal":
|
|
41
|
+
if is_windows:
|
|
42
|
+
keyboard.press_and_release("ctrl+v")
|
|
43
|
+
else:
|
|
44
|
+
keyboard.press_and_release("ctrl+shift+v")
|
|
45
|
+
logging.info("已执行终端粘贴操作")
|
|
46
|
+
elif action == "type":
|
|
47
|
+
keyboard.write(text)
|
|
48
|
+
logging.info("已执行键入操作")
|
|
49
|
+
except Exception as e:
|
|
50
|
+
logging.warning(f"自动粘贴失败(文本已复制到剪贴板): {e}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _save_clipboard() -> bytes | None:
|
|
54
|
+
"""保存当前剪贴板内容,失败返回 None"""
|
|
55
|
+
try:
|
|
56
|
+
import pyclip
|
|
57
|
+
return pyclip.paste()
|
|
58
|
+
except Exception:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _restore_clipboard(old_content: bytes | None):
|
|
63
|
+
"""恢复剪贴板内容"""
|
|
64
|
+
if old_content is None:
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
import pyclip
|
|
68
|
+
time.sleep(_RESTORE_DELAY)
|
|
69
|
+
pyclip.copy(old_content)
|
|
70
|
+
logging.info("已恢复原有剪贴板内容")
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logging.warning(f"恢复剪贴板失败: {e}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_app(config: AppConfig) -> Flask:
|
|
76
|
+
"""应用工厂:根据配置创建 Flask 实例"""
|
|
77
|
+
|
|
78
|
+
template_dir = os.path.join(os.path.dirname(__file__), "templates")
|
|
79
|
+
app = Flask(__name__, template_folder=template_dir)
|
|
80
|
+
app.config["MAX_CONTENT_LENGTH"] = config.max_content_length
|
|
81
|
+
|
|
82
|
+
# 存储配置到 app 上下文
|
|
83
|
+
app.voice_config = config
|
|
84
|
+
app.voice_history = deque(maxlen=config.history_size)
|
|
85
|
+
app.voice_history_lock = threading.Lock()
|
|
86
|
+
app.voice_history_counter = 0
|
|
87
|
+
|
|
88
|
+
# ==================== 路由 ====================
|
|
89
|
+
|
|
90
|
+
@app.route("/", methods=["GET"])
|
|
91
|
+
def index():
|
|
92
|
+
local_ip = get_local_ip()
|
|
93
|
+
cfg = app.voice_config
|
|
94
|
+
return render_template(
|
|
95
|
+
"index.html",
|
|
96
|
+
server_ip=local_ip,
|
|
97
|
+
port=cfg.port,
|
|
98
|
+
require_token=bool(cfg.token) or cfg.require_token,
|
|
99
|
+
auto_paste=cfg.auto_paste,
|
|
100
|
+
platform_name=platform.system(),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@app.route("/status", methods=["GET"])
|
|
104
|
+
def status():
|
|
105
|
+
local_ip = get_local_ip()
|
|
106
|
+
cfg = app.voice_config
|
|
107
|
+
return jsonify(
|
|
108
|
+
{
|
|
109
|
+
"code": 200,
|
|
110
|
+
"message": "service running",
|
|
111
|
+
"version": "2.0.0",
|
|
112
|
+
"server_ip": local_ip,
|
|
113
|
+
"port": cfg.port,
|
|
114
|
+
"platform": platform.system(),
|
|
115
|
+
"require_token": bool(cfg.token) or cfg.require_token,
|
|
116
|
+
"auto_paste": cfg.auto_paste,
|
|
117
|
+
"history_size": cfg.history_size,
|
|
118
|
+
"timestamp": int(time.time() * 1000),
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
@app.route("/history", methods=["GET"])
|
|
123
|
+
def get_history():
|
|
124
|
+
with app.voice_history_lock:
|
|
125
|
+
items = list(app.voice_history)
|
|
126
|
+
return jsonify(
|
|
127
|
+
{
|
|
128
|
+
"code": 200,
|
|
129
|
+
"message": "success",
|
|
130
|
+
"items": items,
|
|
131
|
+
"timestamp": int(time.time() * 1000),
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
@app.route("/history/<int:item_id>", methods=["DELETE"])
|
|
136
|
+
def delete_history_item(item_id):
|
|
137
|
+
with app.voice_history_lock:
|
|
138
|
+
before = len(app.voice_history)
|
|
139
|
+
app.voice_history = deque(
|
|
140
|
+
(item for item in app.voice_history if item.get("id") != item_id),
|
|
141
|
+
maxlen=config.history_size,
|
|
142
|
+
)
|
|
143
|
+
removed = before - len(app.voice_history)
|
|
144
|
+
return jsonify({"code": 200, "message": "success", "removed": removed})
|
|
145
|
+
|
|
146
|
+
@app.route("/history", methods=["DELETE"])
|
|
147
|
+
def clear_history():
|
|
148
|
+
with app.voice_history_lock:
|
|
149
|
+
count = len(app.voice_history)
|
|
150
|
+
app.voice_history.clear()
|
|
151
|
+
return jsonify({"code": 200, "message": "success", "cleared": count})
|
|
152
|
+
|
|
153
|
+
@app.route("/history/export", methods=["GET"])
|
|
154
|
+
def export_history():
|
|
155
|
+
fmt = request.args.get("format", "json")
|
|
156
|
+
with app.voice_history_lock:
|
|
157
|
+
items = list(app.voice_history)
|
|
158
|
+
|
|
159
|
+
if fmt == "csv":
|
|
160
|
+
output = io.StringIO()
|
|
161
|
+
writer = csv.writer(output)
|
|
162
|
+
writer.writerow(["id", "time", "text", "action", "device_id", "client_ip"])
|
|
163
|
+
for item in items:
|
|
164
|
+
t = time.strftime(
|
|
165
|
+
"%Y-%m-%d %H:%M:%S", time.localtime(item["server_time"] / 1000)
|
|
166
|
+
)
|
|
167
|
+
writer.writerow(
|
|
168
|
+
[
|
|
169
|
+
item.get("id", ""),
|
|
170
|
+
t,
|
|
171
|
+
item.get("text", ""),
|
|
172
|
+
item.get("action", ""),
|
|
173
|
+
item.get("device_id", ""),
|
|
174
|
+
item.get("client_ip", ""),
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
return Response(
|
|
178
|
+
output.getvalue(),
|
|
179
|
+
mimetype="text/csv",
|
|
180
|
+
headers={"Content-Disposition": "attachment; filename=voice_history.csv"},
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
return Response(
|
|
184
|
+
json.dumps(items, ensure_ascii=False, indent=2),
|
|
185
|
+
mimetype="application/json",
|
|
186
|
+
headers={
|
|
187
|
+
"Content-Disposition": "attachment; filename=voice_history.json"
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
@app.route("/input", methods=["POST"])
|
|
192
|
+
def handle_input():
|
|
193
|
+
cfg = app.voice_config
|
|
194
|
+
|
|
195
|
+
# 1. IP 白名单验证
|
|
196
|
+
client_ip = get_client_ip(request)
|
|
197
|
+
if not is_ip_allowed(client_ip, cfg.allowed_ips):
|
|
198
|
+
logging.warning(f"IP未授权访问: {client_ip}")
|
|
199
|
+
return (
|
|
200
|
+
jsonify(
|
|
201
|
+
{
|
|
202
|
+
"code": 403,
|
|
203
|
+
"message": "IP not allowed",
|
|
204
|
+
"error_detail": "Your IP address is not in the whitelist",
|
|
205
|
+
}
|
|
206
|
+
),
|
|
207
|
+
403,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# 2. JSON 解析
|
|
211
|
+
try:
|
|
212
|
+
data = request.get_json(force=True)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logging.error(f"JSON解析失败: {e}")
|
|
215
|
+
return (
|
|
216
|
+
jsonify(
|
|
217
|
+
{
|
|
218
|
+
"code": 400,
|
|
219
|
+
"message": "Invalid JSON format",
|
|
220
|
+
"error_detail": "Request body must be valid JSON",
|
|
221
|
+
}
|
|
222
|
+
),
|
|
223
|
+
400,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# 3. Token 校验
|
|
227
|
+
if not is_token_valid(request, data, cfg.token, cfg.require_token):
|
|
228
|
+
logging.warning(f"Token校验失败: {client_ip}")
|
|
229
|
+
return (
|
|
230
|
+
jsonify(
|
|
231
|
+
{
|
|
232
|
+
"code": 401,
|
|
233
|
+
"message": "Unauthorized",
|
|
234
|
+
"error_detail": "Invalid or missing token",
|
|
235
|
+
}
|
|
236
|
+
),
|
|
237
|
+
401,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# 4. 必需字段
|
|
241
|
+
if not data or "text" not in data:
|
|
242
|
+
logging.error("缺少必需字段 'text'")
|
|
243
|
+
return (
|
|
244
|
+
jsonify(
|
|
245
|
+
{
|
|
246
|
+
"code": 400,
|
|
247
|
+
"message": "Missing required field: text",
|
|
248
|
+
"error_detail": 'The "text" field is required',
|
|
249
|
+
}
|
|
250
|
+
),
|
|
251
|
+
400,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# 5. 解析字段
|
|
255
|
+
text = str(data["text"])
|
|
256
|
+
timestamp = data.get("timestamp", int(time.time() * 1000))
|
|
257
|
+
device_id = data.get("device_id", "unknown")
|
|
258
|
+
action = data.get("action", "paste" if cfg.auto_paste else "copy")
|
|
259
|
+
restore_clipboard = bool(data.get("restore_clipboard", False))
|
|
260
|
+
|
|
261
|
+
# 6. 时间戳偏差警告
|
|
262
|
+
current_time = int(time.time() * 1000)
|
|
263
|
+
if abs(current_time - timestamp) > 30000:
|
|
264
|
+
logging.warning(f"时间戳偏差过大: {current_time - timestamp}ms")
|
|
265
|
+
|
|
266
|
+
# 7. 执行剪贴板和键盘操作
|
|
267
|
+
try:
|
|
268
|
+
import pyclip
|
|
269
|
+
|
|
270
|
+
# 仅在需要恢复且不是"仅复制"模式时保存原剪贴板
|
|
271
|
+
need_restore = restore_clipboard and action != "copy"
|
|
272
|
+
old_clipboard = _save_clipboard() if need_restore else None
|
|
273
|
+
|
|
274
|
+
pyclip.copy(text)
|
|
275
|
+
|
|
276
|
+
with app.voice_history_lock:
|
|
277
|
+
app.voice_history_counter += 1
|
|
278
|
+
app.voice_history.appendleft(
|
|
279
|
+
{
|
|
280
|
+
"id": app.voice_history_counter,
|
|
281
|
+
"server_time": current_time,
|
|
282
|
+
"client_ip": client_ip,
|
|
283
|
+
"device_id": device_id,
|
|
284
|
+
"action": action,
|
|
285
|
+
"text": text,
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
logging.info(
|
|
290
|
+
f"已复制到剪贴板 (长度: {len(text)}, 设备: {device_id}, "
|
|
291
|
+
f"IP: {client_ip}, action: {action})"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if action in ("paste", "paste_terminal", "type"):
|
|
295
|
+
_do_keyboard_action(action, text)
|
|
296
|
+
|
|
297
|
+
# 恢复原有剪贴板内容
|
|
298
|
+
if need_restore:
|
|
299
|
+
_restore_clipboard(old_clipboard)
|
|
300
|
+
|
|
301
|
+
return jsonify(
|
|
302
|
+
{
|
|
303
|
+
"code": 200,
|
|
304
|
+
"message": "success",
|
|
305
|
+
"server_time": current_time,
|
|
306
|
+
"processed_text_length": len(text),
|
|
307
|
+
"action": action,
|
|
308
|
+
"device_id": device_id,
|
|
309
|
+
"clipboard_restored": need_restore,
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logging.error(f"服务器内部错误: {e}")
|
|
315
|
+
return (
|
|
316
|
+
jsonify(
|
|
317
|
+
{
|
|
318
|
+
"code": 500,
|
|
319
|
+
"message": "Internal server error",
|
|
320
|
+
"error_detail": str(e),
|
|
321
|
+
}
|
|
322
|
+
),
|
|
323
|
+
500,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# ==================== 错误处理器 ====================
|
|
327
|
+
|
|
328
|
+
@app.errorhandler(413)
|
|
329
|
+
def request_entity_too_large(error):
|
|
330
|
+
return (
|
|
331
|
+
jsonify(
|
|
332
|
+
{
|
|
333
|
+
"code": 413,
|
|
334
|
+
"message": "Payload too large",
|
|
335
|
+
"error_detail": f"Exceeds {config.max_content_length} bytes",
|
|
336
|
+
}
|
|
337
|
+
),
|
|
338
|
+
413,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return app
|