@rookiestar/eng-lang-tutor 1.0.2 → 1.0.4
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.
Potentially problematic release.
This version of @rookiestar/eng-lang-tutor might be problematic. Click here for more details.
- package/CLAUDE.md +5 -4
- package/README.md +16 -1
- package/README_EN.md +16 -1
- package/SKILL.md +113 -92
- package/docs/OPENCLAW_DEPLOYMENT.md +7 -4
- package/package.json +1 -1
- package/requirements.txt +2 -0
- package/scripts/__pycache__/audio_composer.cpython-313.pyc +0 -0
- package/scripts/__pycache__/audio_converter.cpython-313.pyc +0 -0
- package/scripts/__pycache__/audio_enhancer.cpython-313.pyc +0 -0
- package/scripts/__pycache__/state_manager.cpython-313.pyc +0 -0
- package/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/scripts/audio_composer.py +389 -0
- package/scripts/audio_converter.py +245 -0
- package/scripts/cli.py +223 -0
- package/scripts/constants.py +56 -0
- package/scripts/feishu_voice.py +421 -0
- package/scripts/gamification.py +48 -34
- package/scripts/scorer.py +14 -32
- package/scripts/state_manager.py +131 -207
- package/scripts/tts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/tts/__pycache__/base.cpython-313.pyc +0 -0
- package/scripts/tts/__pycache__/manager.cpython-313.pyc +0 -0
- package/scripts/tts/manager.py +14 -4
- package/scripts/tts/providers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/scripts/tts/providers/__pycache__/xunfei.cpython-313.pyc +0 -0
- package/scripts/tts/providers/xunfei.py +10 -1
- package/scripts/utils.py +78 -0
- package/templates/prompt_templates.md +47 -1235
- package/templates/prompts/display_guide.md +106 -0
- package/templates/prompts/initialization.md +213 -0
- package/templates/prompts/keypoint_generation.md +268 -0
- package/templates/prompts/output_rules.md +106 -0
- package/templates/prompts/quiz_generation.md +187 -0
- package/templates/prompts/responses.md +291 -0
- package/templates/prompts/shared_enums.md +97 -0
- package/templates/state_schema.json +1 -6
package/scripts/cli.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CLI interface for eng-lang-tutor state management.
|
|
4
|
+
|
|
5
|
+
This module provides command-line access to state management operations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from state_manager import StateManager
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main():
|
|
17
|
+
"""Main CLI entry point."""
|
|
18
|
+
parser = argparse.ArgumentParser(description="State Manager for eng-lang-tutor")
|
|
19
|
+
parser.add_argument('--data-dir', default=None,
|
|
20
|
+
help='Data directory path (default: ~/.openclaw/state/eng-lang-tutor or OPENCLAW_STATE_DIR env)')
|
|
21
|
+
parser.add_argument('command', nargs='?',
|
|
22
|
+
choices=['show', 'backup', 'save_daily', 'record_view',
|
|
23
|
+
'stats', 'config', 'errors', 'schedule',
|
|
24
|
+
'generate_audio'],
|
|
25
|
+
help='Command to execute')
|
|
26
|
+
parser.add_argument('--content-type', help='Content type for save_daily (keypoint, quiz)')
|
|
27
|
+
parser.add_argument('--content', help='JSON content for save_daily')
|
|
28
|
+
parser.add_argument('--date', help='Date for content (YYYY-MM-DD format)')
|
|
29
|
+
# Errors command options
|
|
30
|
+
parser.add_argument('--page', type=int, default=1, help='Page number for errors list')
|
|
31
|
+
parser.add_argument('--per-page', type=int, default=5, help='Items per page for errors')
|
|
32
|
+
parser.add_argument('--month', help='Filter errors by month (YYYY-MM)')
|
|
33
|
+
parser.add_argument('--random', type=int, help='Get N random errors')
|
|
34
|
+
parser.add_argument('--stats', action='store_true', help='Get error statistics')
|
|
35
|
+
parser.add_argument('--review', type=int, help='Get N errors for review session')
|
|
36
|
+
# Config command options
|
|
37
|
+
parser.add_argument('--cefr', help='Set CEFR level (A1-C2)')
|
|
38
|
+
parser.add_argument('--style', help='Set tutor style')
|
|
39
|
+
parser.add_argument('--oral-ratio', type=int, help='Set oral/written ratio (0-100)')
|
|
40
|
+
# Schedule command options
|
|
41
|
+
parser.add_argument('--keypoint-time', help='Set keypoint push time (HH:MM)')
|
|
42
|
+
parser.add_argument('--quiz-time', help='Set quiz push time (HH:MM)')
|
|
43
|
+
|
|
44
|
+
args = parser.parse_args()
|
|
45
|
+
|
|
46
|
+
sm = StateManager(args.data_dir)
|
|
47
|
+
|
|
48
|
+
if args.command == 'show' or not args.command:
|
|
49
|
+
state = sm.load_state()
|
|
50
|
+
print(json.dumps(state, indent=2, ensure_ascii=False))
|
|
51
|
+
|
|
52
|
+
elif args.command == 'backup':
|
|
53
|
+
backup_path = sm.backup_state()
|
|
54
|
+
print(f"Backup created: {backup_path}")
|
|
55
|
+
|
|
56
|
+
elif args.command == 'save_daily':
|
|
57
|
+
if not args.content_type or not args.content:
|
|
58
|
+
print("Error: --content-type and --content are required for save_daily")
|
|
59
|
+
exit(1)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
content = json.loads(args.content)
|
|
63
|
+
except json.JSONDecodeError as e:
|
|
64
|
+
print(f"Error: Invalid JSON content: {e}")
|
|
65
|
+
exit(1)
|
|
66
|
+
|
|
67
|
+
target_date = None
|
|
68
|
+
if args.date:
|
|
69
|
+
try:
|
|
70
|
+
target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
|
|
71
|
+
except ValueError:
|
|
72
|
+
print("Error: Invalid date format. Use YYYY-MM-DD")
|
|
73
|
+
exit(1)
|
|
74
|
+
|
|
75
|
+
path = sm.save_daily_content(args.content_type, content, target_date)
|
|
76
|
+
print(f"Saved to: {path}")
|
|
77
|
+
|
|
78
|
+
elif args.command == 'record_view':
|
|
79
|
+
target_date = None
|
|
80
|
+
if args.date:
|
|
81
|
+
try:
|
|
82
|
+
target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
|
|
83
|
+
except ValueError:
|
|
84
|
+
print("Error: Invalid date format. Use YYYY-MM-DD")
|
|
85
|
+
exit(1)
|
|
86
|
+
|
|
87
|
+
state = sm.load_state()
|
|
88
|
+
sm.record_keypoint_view(state, target_date)
|
|
89
|
+
sm.save_state(state)
|
|
90
|
+
print("View recorded successfully")
|
|
91
|
+
|
|
92
|
+
elif args.command == 'stats':
|
|
93
|
+
"""Display learning progress summary."""
|
|
94
|
+
from gamification import GamificationManager
|
|
95
|
+
state = sm.load_state()
|
|
96
|
+
gm = GamificationManager()
|
|
97
|
+
summary = gm.get_progress_summary(state)
|
|
98
|
+
print(json.dumps(summary, indent=2, ensure_ascii=False))
|
|
99
|
+
|
|
100
|
+
elif args.command == 'config':
|
|
101
|
+
"""Display or update user configuration."""
|
|
102
|
+
state = sm.load_state()
|
|
103
|
+
|
|
104
|
+
# If no update options, just show current config
|
|
105
|
+
if not any([args.cefr, args.style, args.oral_ratio is not None]):
|
|
106
|
+
config = {
|
|
107
|
+
"cefr_level": state.get("preferences", {}).get("cefr_level", "B1"),
|
|
108
|
+
"tutor_style": state.get("preferences", {}).get("tutor_style", "humorous"),
|
|
109
|
+
"oral_ratio": state.get("preferences", {}).get("oral_ratio", 70),
|
|
110
|
+
"topic_weights": state.get("preferences", {}).get("topic_weights", {}),
|
|
111
|
+
"schedule": state.get("schedule", {})
|
|
112
|
+
}
|
|
113
|
+
print(json.dumps(config, indent=2, ensure_ascii=False))
|
|
114
|
+
else:
|
|
115
|
+
# Update configuration
|
|
116
|
+
if args.cefr:
|
|
117
|
+
if args.cefr not in ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']:
|
|
118
|
+
print("Error: Invalid CEFR level. Must be A1, A2, B1, B2, C1, or C2")
|
|
119
|
+
exit(1)
|
|
120
|
+
state = sm.update_preferences(state, cefr_level=args.cefr)
|
|
121
|
+
print(f"Updated CEFR level to: {args.cefr}")
|
|
122
|
+
|
|
123
|
+
if args.style:
|
|
124
|
+
if args.style not in ['humorous', 'rigorous', 'casual', 'professional']:
|
|
125
|
+
print("Error: Invalid style. Must be humorous, rigorous, casual, or professional")
|
|
126
|
+
exit(1)
|
|
127
|
+
state = sm.update_preferences(state, tutor_style=args.style)
|
|
128
|
+
print(f"Updated tutor style to: {args.style}")
|
|
129
|
+
|
|
130
|
+
if args.oral_ratio is not None:
|
|
131
|
+
if not 0 <= args.oral_ratio <= 100:
|
|
132
|
+
print("Error: Oral ratio must be between 0 and 100")
|
|
133
|
+
exit(1)
|
|
134
|
+
state = sm.update_preferences(state, oral_ratio=args.oral_ratio)
|
|
135
|
+
print(f"Updated oral ratio to: {args.oral_ratio}%")
|
|
136
|
+
|
|
137
|
+
sm.save_state(state)
|
|
138
|
+
print("Configuration updated successfully")
|
|
139
|
+
|
|
140
|
+
elif args.command == 'errors':
|
|
141
|
+
"""Error notebook operations."""
|
|
142
|
+
state = sm.load_state()
|
|
143
|
+
|
|
144
|
+
if args.stats:
|
|
145
|
+
# Get error statistics
|
|
146
|
+
stats = sm.get_error_stats(state)
|
|
147
|
+
print(json.dumps(stats, indent=2, ensure_ascii=False))
|
|
148
|
+
|
|
149
|
+
elif args.review is not None:
|
|
150
|
+
# Get errors for review session
|
|
151
|
+
errors = sm.get_review_errors(state, count=args.review)
|
|
152
|
+
result = {
|
|
153
|
+
"count": len(errors),
|
|
154
|
+
"errors": errors
|
|
155
|
+
}
|
|
156
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
157
|
+
|
|
158
|
+
else:
|
|
159
|
+
# Get paginated errors list
|
|
160
|
+
result = sm.get_errors_page(
|
|
161
|
+
state,
|
|
162
|
+
page=args.page,
|
|
163
|
+
per_page=args.per_page,
|
|
164
|
+
month=args.month,
|
|
165
|
+
random=args.random
|
|
166
|
+
)
|
|
167
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
168
|
+
|
|
169
|
+
elif args.command == 'schedule':
|
|
170
|
+
"""Display or update schedule configuration."""
|
|
171
|
+
state = sm.load_state()
|
|
172
|
+
|
|
173
|
+
# If no update options, just show current schedule
|
|
174
|
+
if not any([args.keypoint_time, args.quiz_time]):
|
|
175
|
+
schedule = state.get("schedule", {})
|
|
176
|
+
print(json.dumps(schedule, indent=2, ensure_ascii=False))
|
|
177
|
+
else:
|
|
178
|
+
# Validate quiz_time must be later than keypoint_time
|
|
179
|
+
current_keypoint = state.get("schedule", {}).get("keypoint_time", "06:45")
|
|
180
|
+
current_quiz = state.get("schedule", {}).get("quiz_time", "22:45")
|
|
181
|
+
|
|
182
|
+
new_keypoint = args.keypoint_time or current_keypoint
|
|
183
|
+
new_quiz = args.quiz_time or current_quiz
|
|
184
|
+
|
|
185
|
+
# Time validation
|
|
186
|
+
def parse_time(t):
|
|
187
|
+
h, m = map(int, t.split(':'))
|
|
188
|
+
return h * 60 + m
|
|
189
|
+
|
|
190
|
+
if parse_time(new_quiz) <= parse_time(new_keypoint):
|
|
191
|
+
print("Error: Quiz time must be later than keypoint time")
|
|
192
|
+
exit(1)
|
|
193
|
+
|
|
194
|
+
state = sm.update_schedule(
|
|
195
|
+
state,
|
|
196
|
+
keypoint_time=new_keypoint,
|
|
197
|
+
quiz_time=new_quiz
|
|
198
|
+
)
|
|
199
|
+
sm.save_state(state)
|
|
200
|
+
print(f"Schedule updated: keypoint at {new_keypoint}, quiz at {new_quiz}")
|
|
201
|
+
|
|
202
|
+
elif args.command == 'generate_audio':
|
|
203
|
+
"""Generate audio for a keypoint."""
|
|
204
|
+
target_date = None
|
|
205
|
+
if args.date:
|
|
206
|
+
try:
|
|
207
|
+
target_date = datetime.strptime(args.date, '%Y-%m-%d').date()
|
|
208
|
+
except ValueError:
|
|
209
|
+
print("Error: Invalid date format. Use YYYY-MM-DD")
|
|
210
|
+
exit(1)
|
|
211
|
+
|
|
212
|
+
result = sm.generate_keypoint_audio(target_date)
|
|
213
|
+
|
|
214
|
+
if result.get('success'):
|
|
215
|
+
print(f"Audio generated: {result.get('audio_path')}")
|
|
216
|
+
print(f"Duration: {result.get('duration_seconds', 0):.1f} seconds")
|
|
217
|
+
else:
|
|
218
|
+
print(f"Failed to generate audio: {result.get('error_message')}")
|
|
219
|
+
exit(1)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Shared constants for eng-lang-tutor.
|
|
4
|
+
|
|
5
|
+
This module contains constants used across multiple scripts to avoid duplication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Level thresholds (XP needed for each level)
|
|
9
|
+
# Note: Level is Activity Level (活跃等级), measuring engagement depth,
|
|
10
|
+
# NOT language ability (which is CEFR level A1-C2)
|
|
11
|
+
LEVEL_THRESHOLDS = [
|
|
12
|
+
0, 50, 100, 200, 350, # 1-5 Starter (启程者)
|
|
13
|
+
550, 800, 1100, 1500, 2000, # 6-10 Traveler (行路人)
|
|
14
|
+
2600, 3300, 4100, 5000, 6000, # 11-15 Explorer (探索者)
|
|
15
|
+
7200, 8500, 10000, 12000, 15000 # 16-20 Pioneer (开拓者)
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# Level range to name mapping
|
|
19
|
+
LEVEL_NAMES = {
|
|
20
|
+
(1, 5): "Starter", # 启程者
|
|
21
|
+
(6, 10): "Traveler", # 行路人
|
|
22
|
+
(11, 15): "Explorer", # 探索者
|
|
23
|
+
(16, 20): "Pioneer" # 开拓者
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_level_name(level: int) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Get the journey stage name for a level.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
level: Activity level (1-20)
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Stage name (Starter/Traveler/Explorer/Pioneer)
|
|
36
|
+
"""
|
|
37
|
+
for (min_level, max_level), name in LEVEL_NAMES.items():
|
|
38
|
+
if min_level <= level <= max_level:
|
|
39
|
+
return name
|
|
40
|
+
return "Unknown"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def calculate_level(xp: int) -> int:
|
|
44
|
+
"""
|
|
45
|
+
Calculate level from total XP.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
xp: Total experience points
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Level (1-20)
|
|
52
|
+
"""
|
|
53
|
+
for i in range(len(LEVEL_THRESHOLDS) - 1, -1, -1):
|
|
54
|
+
if xp >= LEVEL_THRESHOLDS[i]:
|
|
55
|
+
return i + 1
|
|
56
|
+
return 1
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
飞书语音消息发送模块
|
|
4
|
+
|
|
5
|
+
将 TTS 生成的音频转换为飞书语音格式并发送。
|
|
6
|
+
|
|
7
|
+
使用示例:
|
|
8
|
+
from scripts.feishu_voice import FeishuVoiceSender
|
|
9
|
+
|
|
10
|
+
sender = FeishuVoiceSender(app_id="xxx", app_secret="xxx")
|
|
11
|
+
|
|
12
|
+
# 发送单条语音
|
|
13
|
+
await sender.send_voice(
|
|
14
|
+
receive_id="ou_xxx",
|
|
15
|
+
text="Hello, nice to meet you!"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# 发送知识点音频
|
|
19
|
+
await sender.send_keypoint_voices(
|
|
20
|
+
receive_id="ou_xxx",
|
|
21
|
+
keypoint=keypoint,
|
|
22
|
+
audio_info=audio_info
|
|
23
|
+
)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import asyncio
|
|
28
|
+
import aiohttp
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Optional, Dict, Any, List
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
|
|
33
|
+
from .audio_converter import AudioConverter, ConversionResult
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class VoiceSendResult:
|
|
38
|
+
"""语音发送结果"""
|
|
39
|
+
success: bool
|
|
40
|
+
message_id: Optional[str] = None
|
|
41
|
+
error_message: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FeishuVoiceSender:
|
|
45
|
+
"""
|
|
46
|
+
飞书语音消息发送器
|
|
47
|
+
|
|
48
|
+
工作流程:
|
|
49
|
+
1. TTS 生成 MP3 音频
|
|
50
|
+
2. 转换为 Opus 格式(飞书推荐)
|
|
51
|
+
3. 上传到飞书素材库
|
|
52
|
+
4. 发送语音消息
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
FEISHU_API_BASE = "https://open.feishu.cn/open-apis"
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
app_id: Optional[str] = None,
|
|
60
|
+
app_secret: Optional[str] = None,
|
|
61
|
+
tenant_key: Optional[str] = None,
|
|
62
|
+
audio_dir: Optional[Path] = None
|
|
63
|
+
):
|
|
64
|
+
"""
|
|
65
|
+
初始化飞书语音发送器
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
app_id: 飞书应用 ID(可从环境变量 FEISHU_APP_ID 读取)
|
|
69
|
+
app_secret: 飞书应用密钥(可从环境变量 FEISHU_APP_SECRET 读取)
|
|
70
|
+
tenant_key: 租户密钥(自建应用无需)
|
|
71
|
+
audio_dir: 音频缓存目录
|
|
72
|
+
"""
|
|
73
|
+
self.app_id = app_id or os.getenv("FEISHU_APP_ID")
|
|
74
|
+
self.app_secret = app_secret or os.getenv("FEISHU_APP_SECRET")
|
|
75
|
+
self.tenant_key = tenant_key
|
|
76
|
+
|
|
77
|
+
if not self.app_id or not self.app_secret:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"Missing Feishu credentials. Set FEISHU_APP_ID and FEISHU_APP_SECRET "
|
|
80
|
+
"environment variables or pass them to constructor."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
self.audio_dir = audio_dir or Path(
|
|
84
|
+
os.getenv("OPENCLAW_STATE_DIR", "~/.openclaw/state/eng-lang-tutor")
|
|
85
|
+
).expanduser() / "audio"
|
|
86
|
+
self.audio_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
self.converter = AudioConverter()
|
|
89
|
+
self._access_token: Optional[str] = None
|
|
90
|
+
self._token_expires: float = 0
|
|
91
|
+
|
|
92
|
+
async def _get_access_token(self) -> str:
|
|
93
|
+
"""获取飞书访问令牌"""
|
|
94
|
+
import time
|
|
95
|
+
|
|
96
|
+
# 检查缓存
|
|
97
|
+
if self._access_token and time.time() < self._token_expires:
|
|
98
|
+
return self._access_token
|
|
99
|
+
|
|
100
|
+
url = f"{self.FEISHU_API_BASE}/auth/v3/tenant_access_token/internal"
|
|
101
|
+
headers = {"Content-Type": "application/json"}
|
|
102
|
+
data = {
|
|
103
|
+
"app_id": self.app_id,
|
|
104
|
+
"app_secret": self.app_secret
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async with aiohttp.ClientSession() as session:
|
|
108
|
+
async with session.post(url, headers=headers, json=data) as resp:
|
|
109
|
+
result = await resp.json()
|
|
110
|
+
|
|
111
|
+
if result.get("code") != 0:
|
|
112
|
+
raise RuntimeError(f"Failed to get access token: {result}")
|
|
113
|
+
|
|
114
|
+
self._access_token = result["tenant_access_token"]
|
|
115
|
+
self._token_expires = time.time() + result.get("expire", 7200) - 300
|
|
116
|
+
|
|
117
|
+
return self._access_token
|
|
118
|
+
|
|
119
|
+
async def _upload_file(self, file_path: Path, file_type: str = "opus") -> str:
|
|
120
|
+
"""
|
|
121
|
+
上传文件到飞书素材库
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
file_path: 文件路径
|
|
125
|
+
file_type: 文件类型
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
file_key
|
|
129
|
+
"""
|
|
130
|
+
token = await self._get_access_token()
|
|
131
|
+
|
|
132
|
+
url = f"{self.FEISHU_API_BASE}/im/v1/files"
|
|
133
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
134
|
+
|
|
135
|
+
async with aiohttp.ClientSession() as session:
|
|
136
|
+
with open(file_path, "rb") as f:
|
|
137
|
+
form = aiohttp.FormData()
|
|
138
|
+
form.add_field("file_type", file_type)
|
|
139
|
+
form.add_field("file_name", file_path.name)
|
|
140
|
+
form.add_field("file", f, filename=file_path.name)
|
|
141
|
+
|
|
142
|
+
async with session.post(url, headers=headers, data=form) as resp:
|
|
143
|
+
result = await resp.json()
|
|
144
|
+
|
|
145
|
+
if result.get("code") != 0:
|
|
146
|
+
raise RuntimeError(f"Failed to upload file: {result}")
|
|
147
|
+
|
|
148
|
+
return result["data"]["file_key"]
|
|
149
|
+
|
|
150
|
+
async def _send_file_message(
|
|
151
|
+
self,
|
|
152
|
+
receive_id: str,
|
|
153
|
+
file_key: str,
|
|
154
|
+
receive_id_type: str = "open_id"
|
|
155
|
+
) -> str:
|
|
156
|
+
"""
|
|
157
|
+
发送文件消息
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
receive_id: 接收者 ID
|
|
161
|
+
file_key: 文件 key
|
|
162
|
+
receive_id_type: 接收者 ID 类型
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
message_id
|
|
166
|
+
"""
|
|
167
|
+
token = await self._get_access_token()
|
|
168
|
+
|
|
169
|
+
url = f"{self.FEISHU_API_BASE}/im/v1/messages"
|
|
170
|
+
headers = {
|
|
171
|
+
"Authorization": f"Bearer {token}",
|
|
172
|
+
"Content-Type": "application/json"
|
|
173
|
+
}
|
|
174
|
+
params = {
|
|
175
|
+
"receive_id_type": receive_id_type
|
|
176
|
+
}
|
|
177
|
+
data = {
|
|
178
|
+
"receive_id": receive_id,
|
|
179
|
+
"msg_type": "file",
|
|
180
|
+
"content": f'{{"file_key": "{file_key}"}}'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async with aiohttp.ClientSession() as session:
|
|
184
|
+
async with session.post(url, headers=headers, params=params, json=data) as resp:
|
|
185
|
+
result = await resp.json()
|
|
186
|
+
|
|
187
|
+
if result.get("code") != 0:
|
|
188
|
+
raise RuntimeError(f"Failed to send message: {result}")
|
|
189
|
+
|
|
190
|
+
return result["data"]["message_id"]
|
|
191
|
+
|
|
192
|
+
async def send_voice(
|
|
193
|
+
self,
|
|
194
|
+
receive_id: str,
|
|
195
|
+
audio_path: Path,
|
|
196
|
+
receive_id_type: str = "open_id",
|
|
197
|
+
auto_convert: bool = True,
|
|
198
|
+
delete_after_send: bool = False
|
|
199
|
+
) -> VoiceSendResult:
|
|
200
|
+
"""
|
|
201
|
+
发送语音消息
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
receive_id: 接收者 ID(open_id / user_id / union_id)
|
|
205
|
+
audio_path: 音频文件路径(MP3 或 Opus)
|
|
206
|
+
receive_id_type: 接收者 ID 类型
|
|
207
|
+
auto_convert: 是否自动转换为 Opus 格式
|
|
208
|
+
delete_after_send: 发送后是否删除临时文件
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
VoiceSendResult
|
|
212
|
+
"""
|
|
213
|
+
audio_path = Path(audio_path)
|
|
214
|
+
temp_file = None
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
# 如果是 MP3,转换为 Opus
|
|
218
|
+
if auto_convert and audio_path.suffix.lower() == ".mp3":
|
|
219
|
+
opus_path = audio_path.with_suffix(".opus")
|
|
220
|
+
result = self.converter.convert_to_voice(
|
|
221
|
+
input_path=audio_path,
|
|
222
|
+
output_path=opus_path,
|
|
223
|
+
format="opus",
|
|
224
|
+
sample_rate=16000
|
|
225
|
+
)
|
|
226
|
+
if not result.success:
|
|
227
|
+
return VoiceSendResult(
|
|
228
|
+
success=False,
|
|
229
|
+
error_message=f"Audio conversion failed: {result.error_message}"
|
|
230
|
+
)
|
|
231
|
+
audio_path = opus_path
|
|
232
|
+
temp_file = opus_path if delete_after_send else None
|
|
233
|
+
|
|
234
|
+
# 上传文件
|
|
235
|
+
file_key = await self._upload_file(audio_path)
|
|
236
|
+
|
|
237
|
+
# 发送消息
|
|
238
|
+
message_id = await self._send_file_message(
|
|
239
|
+
receive_id=receive_id,
|
|
240
|
+
file_key=file_key,
|
|
241
|
+
receive_id_type=receive_id_type
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return VoiceSendResult(
|
|
245
|
+
success=True,
|
|
246
|
+
message_id=message_id
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
except Exception as e:
|
|
250
|
+
return VoiceSendResult(
|
|
251
|
+
success=False,
|
|
252
|
+
error_message=str(e)
|
|
253
|
+
)
|
|
254
|
+
finally:
|
|
255
|
+
# 清理临时文件
|
|
256
|
+
if temp_file and temp_file.exists():
|
|
257
|
+
temp_file.unlink()
|
|
258
|
+
|
|
259
|
+
async def send_voice_from_text(
|
|
260
|
+
self,
|
|
261
|
+
receive_id: str,
|
|
262
|
+
text: str,
|
|
263
|
+
voice: str = "catherine",
|
|
264
|
+
speed: float = 0.9,
|
|
265
|
+
receive_id_type: str = "open_id"
|
|
266
|
+
) -> VoiceSendResult:
|
|
267
|
+
"""
|
|
268
|
+
从文本直接生成并发送语音
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
receive_id: 接收者 ID
|
|
272
|
+
text: 要转换的文本
|
|
273
|
+
voice: 音色
|
|
274
|
+
speed: 语速
|
|
275
|
+
receive_id_type: 接收者 ID 类型
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
VoiceSendResult
|
|
279
|
+
"""
|
|
280
|
+
from .tts import TTSManager
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
# 生成 TTS 音频
|
|
284
|
+
manager = TTSManager.from_env()
|
|
285
|
+
output_path = self.audio_dir / f"temp_{hash(text)}.mp3"
|
|
286
|
+
|
|
287
|
+
result = manager.synthesize(
|
|
288
|
+
text=text,
|
|
289
|
+
output_path=output_path,
|
|
290
|
+
voice=voice,
|
|
291
|
+
speed=speed
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if not result.success:
|
|
295
|
+
return VoiceSendResult(
|
|
296
|
+
success=False,
|
|
297
|
+
error_message=f"TTS failed: {result.error_message}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# 发送语音
|
|
301
|
+
return await self.send_voice(
|
|
302
|
+
receive_id=receive_id,
|
|
303
|
+
audio_path=output_path,
|
|
304
|
+
receive_id_type=receive_id_type,
|
|
305
|
+
auto_convert=True,
|
|
306
|
+
delete_after_send=True
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
except Exception as e:
|
|
310
|
+
return VoiceSendResult(
|
|
311
|
+
success=False,
|
|
312
|
+
error_message=str(e)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
async def send_keypoint_voices(
|
|
316
|
+
self,
|
|
317
|
+
receive_id: str,
|
|
318
|
+
keypoint: Dict[str, Any],
|
|
319
|
+
audio_info: Dict[str, Any],
|
|
320
|
+
receive_id_type: str = "open_id",
|
|
321
|
+
include_dialogue: bool = True,
|
|
322
|
+
include_expressions: bool = True
|
|
323
|
+
) -> List[VoiceSendResult]:
|
|
324
|
+
"""
|
|
325
|
+
发送知识点的所有语音
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
receive_id: 接收者 ID
|
|
329
|
+
keypoint: 知识点数据
|
|
330
|
+
audio_info: TTS 生成的音频信息
|
|
331
|
+
receive_id_type: 接收者 ID 类型
|
|
332
|
+
include_dialogue: 是否发送对话音频
|
|
333
|
+
include_expressions: 是否发送表达音频
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
发送结果列表
|
|
337
|
+
"""
|
|
338
|
+
results = []
|
|
339
|
+
date_str = audio_info.get("generated_at", "")[:10] # YYYY-MM-DD
|
|
340
|
+
|
|
341
|
+
# 发送对话音频
|
|
342
|
+
if include_dialogue:
|
|
343
|
+
for item in audio_info.get("dialogue", []):
|
|
344
|
+
if "error" in item:
|
|
345
|
+
results.append(VoiceSendResult(
|
|
346
|
+
success=False,
|
|
347
|
+
error_message=f"Dialogue generation failed: {item['error']}"
|
|
348
|
+
))
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
audio_url = item.get("audio_url", "")
|
|
352
|
+
if not audio_url:
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
# 解析路径:audio/YYYY-MM-DD/filename.opus
|
|
356
|
+
parts = audio_url.split("/")
|
|
357
|
+
audio_path = self.audio_dir / parts[1] / parts[2]
|
|
358
|
+
|
|
359
|
+
if not audio_path.exists():
|
|
360
|
+
# 尝试 MP3 扩展名
|
|
361
|
+
audio_path = audio_path.with_suffix(".mp3")
|
|
362
|
+
|
|
363
|
+
if audio_path.exists():
|
|
364
|
+
speaker = item.get("speaker", "")
|
|
365
|
+
text = item.get("text", "")
|
|
366
|
+
|
|
367
|
+
result = await self.send_voice(
|
|
368
|
+
receive_id=receive_id,
|
|
369
|
+
audio_path=audio_path,
|
|
370
|
+
receive_id_type=receive_id_type,
|
|
371
|
+
auto_convert=True
|
|
372
|
+
)
|
|
373
|
+
results.append(result)
|
|
374
|
+
|
|
375
|
+
# 发送表达音频
|
|
376
|
+
if include_expressions:
|
|
377
|
+
for item in audio_info.get("expressions", []):
|
|
378
|
+
if "error" in item:
|
|
379
|
+
results.append(VoiceSendResult(
|
|
380
|
+
success=False,
|
|
381
|
+
error_message=f"Expression generation failed: {item['error']}"
|
|
382
|
+
))
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
audio_url = item.get("audio_url", "")
|
|
386
|
+
if not audio_url:
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
parts = audio_url.split("/")
|
|
390
|
+
audio_path = self.audio_dir / parts[1] / parts[2]
|
|
391
|
+
|
|
392
|
+
if not audio_path.exists():
|
|
393
|
+
audio_path = audio_path.with_suffix(".mp3")
|
|
394
|
+
|
|
395
|
+
if audio_path.exists():
|
|
396
|
+
result = await self.send_voice(
|
|
397
|
+
receive_id=receive_id,
|
|
398
|
+
audio_path=audio_path,
|
|
399
|
+
receive_id_type=receive_id_type,
|
|
400
|
+
auto_convert=True
|
|
401
|
+
)
|
|
402
|
+
results.append(result)
|
|
403
|
+
|
|
404
|
+
return results
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# 同步包装器(用于非异步环境)
|
|
408
|
+
class FeishuVoiceSenderSync:
|
|
409
|
+
"""飞书语音发送器的同步包装"""
|
|
410
|
+
|
|
411
|
+
def __init__(self, *args, **kwargs):
|
|
412
|
+
self._async_sender = FeishuVoiceSender(*args, **kwargs)
|
|
413
|
+
|
|
414
|
+
def send_voice(self, *args, **kwargs) -> VoiceSendResult:
|
|
415
|
+
return asyncio.run(self._async_sender.send_voice(*args, **kwargs))
|
|
416
|
+
|
|
417
|
+
def send_voice_from_text(self, *args, **kwargs) -> VoiceSendResult:
|
|
418
|
+
return asyncio.run(self._async_sender.send_voice_from_text(*args, **kwargs))
|
|
419
|
+
|
|
420
|
+
def send_keypoint_voices(self, *args, **kwargs) -> List[VoiceSendResult]:
|
|
421
|
+
return asyncio.run(self._async_sender.send_keypoint_voices(*args, **kwargs))
|