@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.

Files changed (37) hide show
  1. package/CLAUDE.md +5 -4
  2. package/README.md +16 -1
  3. package/README_EN.md +16 -1
  4. package/SKILL.md +113 -92
  5. package/docs/OPENCLAW_DEPLOYMENT.md +7 -4
  6. package/package.json +1 -1
  7. package/requirements.txt +2 -0
  8. package/scripts/__pycache__/audio_composer.cpython-313.pyc +0 -0
  9. package/scripts/__pycache__/audio_converter.cpython-313.pyc +0 -0
  10. package/scripts/__pycache__/audio_enhancer.cpython-313.pyc +0 -0
  11. package/scripts/__pycache__/state_manager.cpython-313.pyc +0 -0
  12. package/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  13. package/scripts/audio_composer.py +389 -0
  14. package/scripts/audio_converter.py +245 -0
  15. package/scripts/cli.py +223 -0
  16. package/scripts/constants.py +56 -0
  17. package/scripts/feishu_voice.py +421 -0
  18. package/scripts/gamification.py +48 -34
  19. package/scripts/scorer.py +14 -32
  20. package/scripts/state_manager.py +131 -207
  21. package/scripts/tts/__pycache__/__init__.cpython-313.pyc +0 -0
  22. package/scripts/tts/__pycache__/base.cpython-313.pyc +0 -0
  23. package/scripts/tts/__pycache__/manager.cpython-313.pyc +0 -0
  24. package/scripts/tts/manager.py +14 -4
  25. package/scripts/tts/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  26. package/scripts/tts/providers/__pycache__/xunfei.cpython-313.pyc +0 -0
  27. package/scripts/tts/providers/xunfei.py +10 -1
  28. package/scripts/utils.py +78 -0
  29. package/templates/prompt_templates.md +47 -1235
  30. package/templates/prompts/display_guide.md +106 -0
  31. package/templates/prompts/initialization.md +213 -0
  32. package/templates/prompts/keypoint_generation.md +268 -0
  33. package/templates/prompts/output_rules.md +106 -0
  34. package/templates/prompts/quiz_generation.md +187 -0
  35. package/templates/prompts/responses.md +291 -0
  36. package/templates/prompts/shared_enums.md +97 -0
  37. 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))