From 220e4fab27b7f419b3e89e2f2d28ae789aac09d7 Mon Sep 17 00:00:00 2001
From: wangzhibo <wangzhibo@shsening.com>
Date: 星期四, 08 十月 2026 16:29:55 +0800
Subject: [PATCH] 5种场景模式家长端ok
---
xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc | 977 +++++++++++++++++++++++++++++++++++++++++++++++++++------
1 files changed, 873 insertions(+), 104 deletions(-)
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
index df9e80e..bc248d6 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/app_task.cc
@@ -1,4 +1,5 @@
#include "app_task.h"
+#include "parent_schedule.h"
#include "application.h"
#include "board.h"
@@ -12,21 +13,45 @@
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
+#include <sys/stat.h>
+#include <dirent.h>
+#include <ctype.h>
+#include <string>
+#include <esp_heap_caps.h>
+#include <esp_timer.h>
#define TAG "app_task"
#define APP_TASK_NVS_NS "app_task"
#define APP_TASK_NVS_KEY "pending"
#define APP_TASK_NVS_LAST_REP "last_rep"
+#define APP_TASK_NVS_LAST_RPT "last_rpt"
+#define APP_TASK_NVS_LAST_SHD "last_shd"
#define APP_TASK_JSON_MAX 768
#define APP_TASK_ID_MAX 32
#define APP_TASK_SOURCE_MAX 24
+#define LESSON_CLIP_NAME_MAX 16
+#define LESSON_CLIP_MAX 128
static char s_current_app[APP_TASK_ID_MAX] = "chat";
static char s_current_task[APP_TASK_ID_MAX] = "conversation";
static char s_current_source[APP_TASK_SOURCE_MAX] = "default_chat";
+static char s_current_params[APP_TASK_JSON_MAX] = "{}";
static int s_current_priority = 0;
static bool s_finish_task_after_mp3 = true;
static float s_shadowing_listen_ratio = 1.2f;
+static bool s_wait_start_phrase = false;
+static bool s_start_phrase_tts_begun = false;
+static bool s_start_detect_sent = false;
+static bool s_listen_after_phrase = false;
+static int64_t s_wait_start_us = 0;
+static std::string s_start_detect_text;
+
+void Board_TrySendLessonStartDetect(void);
+
+bool Board_IsWaitingStartPhrase(void)
+{
+ return s_wait_start_phrase;
+}
typedef struct {
const char *app_id;
@@ -63,6 +88,20 @@
int Board_AppTask_CurrentPriority(void)
{
return s_current_priority;
+}
+
+static bool is_safe_task_id(const char *id)
+{
+ if (!id || id[0] == '\0' || strlen(id) >= APP_TASK_ID_MAX) {
+ return false;
+ }
+ for (const char *p = id; *p; ++p) {
+ unsigned char ch = static_cast<unsigned char>(*p);
+ if (!isalnum(ch) && ch != '_' && ch != '-') {
+ return false;
+ }
+ }
+ return true;
}
static const app_task_skill_map_t *find_task_spec(const char *app_id, const char *task_id)
@@ -376,6 +415,328 @@
return name[0] != '\0';
}
+static const char *last_lesson_nvs_key(const char *task_id)
+{
+ if (task_id && strcmp(task_id, "shadowing") == 0) {
+ return APP_TASK_NVS_LAST_SHD;
+ }
+ if (task_id && strcmp(task_id, "repeater") == 0) {
+ return APP_TASK_NVS_LAST_RPT;
+ }
+ return NULL;
+}
+
+static const char *const k_last_lesson_keys[] = {
+ "lesson_id",
+ "lesson",
+ "lessonId",
+ "en_passes",
+ "enPasses",
+ "cn_repeat",
+ "cn_speed",
+ "language",
+ "lang",
+ "speed",
+ "on_complete",
+ "listen_ratio",
+ "sentence_count",
+ "org_id",
+ "orgId",
+ NULL,
+};
+
+static bool json_has_lesson_id(cJSON *root)
+{
+ if (!root) {
+ return false;
+ }
+ const cJSON *id = cJSON_GetObjectItem(root, "lesson_id");
+ if (!id) {
+ id = cJSON_GetObjectItem(root, "lessonId");
+ }
+ if (!id) {
+ id = cJSON_GetObjectItem(root, "lesson");
+ }
+ return cJSON_IsString(id) && id->valuestring && id->valuestring[0];
+}
+
+static bool compact_last_lesson_json(const char *params_json, char *out, size_t out_size)
+{
+ if (!out || out_size == 0) {
+ return false;
+ }
+ out[0] = '\0';
+ if (!params_json || params_json[0] == '\0' || strcmp(params_json, "{}") == 0) {
+ return false;
+ }
+ cJSON *root = cJSON_Parse(params_json);
+ if (!root) {
+ return false;
+ }
+ cJSON *compact = cJSON_CreateObject();
+ if (!compact) {
+ cJSON_Delete(root);
+ return false;
+ }
+ for (int i = 0; k_last_lesson_keys[i]; ++i) {
+ cJSON *item = cJSON_GetObjectItem(root, k_last_lesson_keys[i]);
+ if (item) {
+ cJSON_AddItemToObject(compact, k_last_lesson_keys[i], cJSON_Duplicate(item, 1));
+ }
+ }
+ bool ok = json_has_lesson_id(compact);
+ if (ok) {
+ const cJSON *id = cJSON_GetObjectItem(compact, "lesson_id");
+ if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
+ id = cJSON_GetObjectItem(compact, "lessonId");
+ if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
+ id = cJSON_GetObjectItem(compact, "lesson");
+ }
+ if (cJSON_IsString(id) && id->valuestring && id->valuestring[0]) {
+ cJSON_AddStringToObject(compact, "lesson_id", id->valuestring);
+ }
+ }
+ char *printed = cJSON_PrintUnformatted(compact);
+ if (printed) {
+ strlcpy(out, printed, out_size);
+ ok = out[0] != '\0';
+ cJSON_free(printed);
+ } else {
+ ok = false;
+ }
+ }
+ cJSON_Delete(compact);
+ cJSON_Delete(root);
+ return ok;
+}
+
+static bool params_has_lesson_id(const char *params_json)
+{
+ if (!params_json || params_json[0] == '\0' || strcmp(params_json, "{}") == 0) {
+ return false;
+ }
+ cJSON *root = cJSON_Parse(params_json);
+ if (!root) {
+ return false;
+ }
+ bool ok = json_has_lesson_id(root);
+ cJSON_Delete(root);
+ return ok;
+}
+
+static bool sd_business_file_ready(const char *business_path)
+{
+ char sd_path[SDCARD_PATH_MAX_LEN];
+ if (!business_path || business_path[0] == '\0') {
+ return false;
+ }
+ if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+ return false;
+ }
+ struct stat st;
+ if (stat(sd_path, &st) != 0) {
+ return false;
+ }
+ return S_ISREG(st.st_mode) && st.st_size > 0;
+}
+
+static const char *json_clip_name(cJSON *obj)
+{
+ if (!cJSON_IsObject(obj)) {
+ return NULL;
+ }
+ const char *keys[] = {"name", "path", "url"};
+ for (size_t i = 0; i < sizeof(keys) / sizeof(keys[0]); ++i) {
+ cJSON *item = cJSON_GetObjectItem(obj, keys[i]);
+ if (cJSON_IsString(item) && item->valuestring && item->valuestring[0] != '\0') {
+ return item->valuestring;
+ }
+ }
+ return NULL;
+}
+
+static int mp3_name_order(const void *a, const void *b)
+{
+ int left = atoi(static_cast<const char *>(a));
+ int right = atoi(static_cast<const char *>(b));
+ if (left != right) {
+ return left - right;
+ }
+ return strcmp(static_cast<const char *>(a), static_cast<const char *>(b));
+}
+
+static int list_lesson_mp3s(const char *business_dir, char names[][LESSON_CLIP_NAME_MAX], int cap)
+{
+ char sd_path[SDCARD_PATH_MAX_LEN];
+ if (!business_dir || business_dir[0] == '\0' || !names || cap < 1) {
+ return 0;
+ }
+ if (SDCard_Map_Business_Path(business_dir, sd_path, sizeof(sd_path)) != ESP_OK) {
+ return 0;
+ }
+ DIR *dir = opendir(sd_path);
+ if (!dir) {
+ return 0;
+ }
+ int count = 0;
+ struct dirent *ent;
+ while ((ent = readdir(dir)) != NULL && count < cap) {
+ const char *name = ent->d_name;
+ size_t len = name ? strlen(name) : 0;
+ if (len < 5 || len >= LESSON_CLIP_NAME_MAX || strcasecmp(name + len - 4, ".mp3") != 0) {
+ continue;
+ }
+ strlcpy(names[count], name, LESSON_CLIP_NAME_MAX);
+ count++;
+ }
+ closedir(dir);
+ if (count > 1) {
+ qsort(names, static_cast<size_t>(count), LESSON_CLIP_NAME_MAX, mp3_name_order);
+ }
+ return count;
+}
+
+static const char *shadowing_lang_folder(const char *language)
+{
+ return (language && strcasecmp(language, "cn") == 0) ? "cn" : "en";
+}
+
+static bool probe_lesson_id_on_sd(const char *task_id, const char *lesson_id, const char *language)
+{
+ int level = 0;
+ int story = 0;
+ if (!lesson_id || sscanf(lesson_id, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
+ return true;
+ }
+ char path[SDCARD_PATH_MAX_LEN];
+ if (task_id && strcmp(task_id, "shadowing") == 0) {
+ const char *lang = shadowing_lang_folder(language);
+ snprintf(path, sizeof(path), "/english_learning/shadowing/lessons/l%d/%03d/%.2s",
+ level, story, lang);
+ char names[1][LESSON_CLIP_NAME_MAX];
+ return list_lesson_mp3s(path, names, 1) >= 1;
+ }
+ snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/cn", level, story);
+ char names[1][LESSON_CLIP_NAME_MAX];
+ if (list_lesson_mp3s(path, names, 1) < 1) {
+ return false;
+ }
+ snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en", level, story);
+ return list_lesson_mp3s(path, names, 1) >= 1;
+}
+
+static bool lesson_files_ready(const char *task_id, const char *params_json)
+{
+ if (!params_json || params_json[0] == '\0') {
+ return true;
+ }
+ cJSON *params = cJSON_Parse(params_json);
+ if (!params) {
+ return true;
+ }
+
+ cJSON *items = cJSON_GetObjectItem(params, "items");
+ bool checked = false;
+ if (cJSON_IsArray(items)) {
+ cJSON *it = NULL;
+ cJSON_ArrayForEach(it, items) {
+ const char *name = json_clip_name(it);
+ if (!name) {
+ continue;
+ }
+ checked = true;
+ if (!sd_business_file_ready(name)) {
+ cJSON_Delete(params);
+ return false;
+ }
+ }
+ if (checked) {
+ cJSON_Delete(params);
+ return true;
+ }
+ }
+
+ const char *single = json_clip_name(params);
+ if (single) {
+ bool ok = sd_business_file_ready(single);
+ cJSON_Delete(params);
+ return ok;
+ }
+
+ cJSON *lesson = cJSON_GetObjectItem(params, "lesson_id");
+ if (!cJSON_IsString(lesson) || !lesson->valuestring || lesson->valuestring[0] == '\0') {
+ lesson = cJSON_GetObjectItem(params, "lesson");
+ }
+ if (!cJSON_IsString(lesson) || !lesson->valuestring || lesson->valuestring[0] == '\0') {
+ lesson = cJSON_GetObjectItem(params, "lessonId");
+ }
+ cJSON *lang = cJSON_GetObjectItem(params, "language");
+ if (!cJSON_IsString(lang) || !lang->valuestring) {
+ lang = cJSON_GetObjectItem(params, "lang");
+ }
+ bool ok = true;
+ if (cJSON_IsString(lesson) && lesson->valuestring && lesson->valuestring[0] != '\0') {
+ ok = probe_lesson_id_on_sd(task_id, lesson->valuestring,
+ cJSON_IsString(lang) ? lang->valuestring : NULL);
+ }
+ cJSON_Delete(params);
+ return ok;
+}
+
+static void show_lesson_sync_alert(void)
+{
+ Board_Show_Alert("姝e湪鍚屾", "姝e湪鍚屾璇炬枃锛岃绋嶅��", "cloud_arrow_down");
+}
+
+bool Board_LessonFilesReady(const char *task_id, const char *params_json)
+{
+ return lesson_files_ready(task_id, params_json);
+}
+
+bool Board_IsLessonPlaybackActive(void)
+{
+ return s_wait_start_phrase || SDCard_Is_Playing();
+}
+
+void Board_RememberLastLessonTask(const char *task_id, const char *params_json)
+{
+ const char *key = last_lesson_nvs_key(task_id);
+ char compact[APP_TASK_JSON_MAX];
+ if (!key || !compact_last_lesson_json(params_json, compact, sizeof(compact))) {
+ return;
+ }
+ nvs_handle_t handle;
+ if (nvs_open(APP_TASK_NVS_NS, NVS_READWRITE, &handle) != ESP_OK) {
+ return;
+ }
+ if (nvs_set_str(handle, key, compact) == ESP_OK) {
+ nvs_commit(handle);
+ ESP_LOGI(TAG, "remember last %s lesson", task_id);
+ }
+ nvs_close(handle);
+}
+
+bool Board_LoadLastLessonTask(const char *task_id, char *buf, size_t buf_size)
+{
+ const char *key = last_lesson_nvs_key(task_id);
+ if (!key || !buf || buf_size == 0) {
+ return false;
+ }
+ buf[0] = '\0';
+ nvs_handle_t handle;
+ if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) != ESP_OK) {
+ return false;
+ }
+ size_t length = buf_size;
+ esp_err_t err = nvs_get_str(handle, key, buf, &length);
+ nvs_close(handle);
+ if (err != ESP_OK || buf[0] == '\0' || !params_has_lesson_id(buf)) {
+ buf[0] = '\0';
+ return false;
+ }
+ return true;
+}
+
esp_err_t Board_SavePendingTask(const char *app_id, const char *task_id, const char *params_json,
const char *source, const char *alarm_id)
{
@@ -417,66 +778,391 @@
return err;
}
-static bool load_pending_fields(char *app_id, size_t app_size, char *task_id, size_t task_size,
- char *source, size_t source_size, char *params_json, size_t params_size)
-{
- nvs_handle_t handle;
- if (nvs_open(APP_TASK_NVS_NS, NVS_READONLY, &handle) != ESP_OK) {
- return false;
- }
- char buf[APP_TASK_JSON_MAX];
- size_t length = sizeof(buf);
- esp_err_t err = nvs_get_str(handle, APP_TASK_NVS_KEY, buf, &length);
- nvs_close(handle);
- if (err != ESP_OK) {
- return false;
- }
-
- cJSON *root = cJSON_Parse(buf);
- if (!root) {
- return false;
- }
- const cJSON *app = cJSON_GetObjectItem(root, "app_id");
- const cJSON *task = cJSON_GetObjectItem(root, "task_id");
- const cJSON *src = cJSON_GetObjectItem(root, "source");
- const cJSON *params = cJSON_GetObjectItem(root, "params");
- copy_id(app_id, app_size, cJSON_IsString(app) ? app->valuestring : NULL, "chat");
- copy_id(task_id, task_size, cJSON_IsString(task) ? task->valuestring : NULL, "conversation");
- copy_id(source, source_size, cJSON_IsString(src) ? src->valuestring : NULL, "default_chat");
- if (params_json && params_size > 0) {
- params_json[0] = '\0';
- if (params) {
- char *printed = cJSON_PrintUnformatted(params);
- if (printed) {
- strlcpy(params_json, printed, params_size);
- free(printed);
- }
- }
- }
- cJSON_Delete(root);
- return app_id[0] != '\0';
-}
-
static void preempt_current_skills(void)
{
SDCard_Stop_Play();
BoardMqtt_StopPlaylistPlayback();
- Application::GetInstance().Schedule([]() {
- auto state = Application::GetInstance().GetDeviceState();
- if (state == kDeviceStateSpeaking) {
- Application::GetInstance().AbortSpeaking(kAbortReasonNone);
+}
+
+static bool is_schedule_talk_task(const char *task_id)
+{
+ return task_id && (strcmp(task_id, "conversation") == 0 ||
+ strcmp(task_id, "live_translate") == 0 ||
+ strcmp(task_id, "bilingual_chat") == 0);
+}
+
+static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle,
+ bool expect_prompt = true, bool sd_ready = false, bool wait_phrase = false)
+{
+ std::string params = s_current_params[0] ? s_current_params : "{}";
+ if (sd_ready && params.size() >= 2 && params.back() == '}') {
+ params.pop_back();
+ if (params.size() > 1) {
+ params += ',';
}
+ params += "\"sd_ready\":true}";
+ }
+ std::string detect = "[app_task]";
+ detect += (app_id && app_id[0]) ? app_id : "chat";
+ detect += "|";
+ detect += (task_id && task_id[0]) ? task_id : "conversation";
+ detect += "|";
+ detect += (source && source[0]) ? source : "default_chat";
+ detect += "|";
+ detect += params;
+ const bool hold_for_phrase = sd_ready || wait_phrase;
+ if (hold_for_phrase) {
+ s_start_detect_text = detect;
+ s_start_detect_sent = false;
+ }
+ Application::GetInstance().Schedule([detect, open_if_idle, expect_prompt, hold_for_phrase]() {
+ auto& app = Application::GetInstance();
+ app.HoldIdleForServerPrompt();
+ if (hold_for_phrase) {
+ // 鍏虫満鎷夎捣鏃堕�氶亾鍙兘杩樻病鎵撳紑銆傝繛涓婂悗鍐嶄笂鎶ワ紝绛夈�屽紑濮媥x浠诲姟銆嶈瀹屽啀杩涘叆浠诲姟銆�
+ Board_TrySendLessonStartDetect();
+ return;
+ }
+ if (expect_prompt && app.GetDeviceState() == kDeviceStateSpeaking) {
+ app.AbortSpeaking(kAbortReasonNone);
+ }
+ if (app.SendListenDetectNow(detect)) {
+ if (expect_prompt) {
+ app.ArmPendingDetectRetry(detect, 5);
+ }
+ return;
+ }
+ app.SendListenDetect(detect, open_if_idle);
});
}
-static void notify_server(const char *app_id, const char *task_id, const char *source, bool open_if_idle)
+static const char *json_lesson_id(cJSON *params)
{
- char detect[160];
- snprintf(detect, sizeof(detect), "[app_task]%s|%s|%s",
- app_id && app_id[0] ? app_id : "chat",
- task_id && task_id[0] ? task_id : "conversation",
- source && source[0] ? source : "default_chat");
- Application::GetInstance().SendListenDetect(detect, open_if_idle);
+ if (!params) {
+ return NULL;
+ }
+ const cJSON *id = cJSON_GetObjectItem(params, "lesson_id");
+ if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
+ id = cJSON_GetObjectItem(params, "lessonId");
+ }
+ if (!cJSON_IsString(id) || !id->valuestring || !id->valuestring[0]) {
+ id = cJSON_GetObjectItem(params, "lesson");
+ }
+ return (cJSON_IsString(id) && id->valuestring && id->valuestring[0]) ? id->valuestring : NULL;
+}
+
+static bool add_playlist_item(sdcard_mp3_playlist_item_t *items, int *n, int cap,
+ const char *business, float speed, int repeat)
+{
+ if (!items || !n || *n >= cap || !business || business[0] == '\0') {
+ return false;
+ }
+ sdcard_mp3_playlist_item_t *item = &items[*n];
+ if (!SDCard_Resolve_MP3_Path(business, item->path, sizeof(item->path))) {
+ ESP_LOGW(TAG, "lesson clip missing on sd: %s", business);
+ return false;
+ }
+ item->speed = SDCard_Clamp_MP3_Speed(speed);
+ item->repeat = repeat < 1 ? 1 : repeat;
+ if (item->repeat > 10) {
+ item->repeat = 10;
+ }
+ item->power_off_after = false;
+ (*n)++;
+ return true;
+}
+
+static int build_repeater_playlist(cJSON *params, sdcard_mp3_playlist_item_t *items, int cap)
+{
+ int level = 0;
+ int story = 0;
+ const char *lesson = json_lesson_id(params);
+ if (!lesson || sscanf(lesson, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
+ return 0;
+ }
+ int cn_repeat = 1;
+ float cn_speed = 1.0f;
+ const cJSON *cn_r = cJSON_GetObjectItem(params, "cn_repeat");
+ if (cJSON_IsNumber(cn_r)) {
+ cn_repeat = cn_r->valueint;
+ }
+ const cJSON *cn_s = cJSON_GetObjectItem(params, "cn_speed");
+ if (cJSON_IsNumber(cn_s)) {
+ cn_speed = static_cast<float>(cn_s->valuedouble);
+ }
+ char dir[SDCARD_PATH_MAX_LEN];
+ char path[SDCARD_PATH_MAX_LEN];
+ static char cn_names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX];
+ static char en_names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX];
+ int n = 0;
+ snprintf(dir, sizeof(dir), "/english_learning/repeater/lessons/l%d/%03d/cn", level, story);
+ int cn_n = list_lesson_mp3s(dir, cn_names, LESSON_CLIP_MAX);
+ snprintf(dir, sizeof(dir), "/english_learning/repeater/lessons/l%d/%03d/en", level, story);
+ int en_n = list_lesson_mp3s(dir, en_names, LESSON_CLIP_MAX);
+ if (cn_n < 1 || en_n < 1) {
+ return 0;
+ }
+ for (int i = 0; i < cn_n; ++i) {
+ snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/cn/%.15s",
+ level, story, cn_names[i]);
+ if (!add_playlist_item(items, &n, cap, path, cn_speed, cn_repeat)) {
+ return n > 0 ? n : 0;
+ }
+ }
+ const cJSON *passes = cJSON_GetObjectItem(params, "en_passes");
+ int en_added = 0;
+ if (cJSON_IsArray(passes)) {
+ cJSON *row = NULL;
+ cJSON_ArrayForEach(row, passes) {
+ if (en_added >= 8) {
+ break;
+ }
+ float speed = 1.0f;
+ int repeat = 1;
+ const cJSON *sp = cJSON_GetObjectItem(row, "speed");
+ const cJSON *rp = cJSON_GetObjectItem(row, "repeat");
+ if (cJSON_IsNumber(sp)) {
+ speed = static_cast<float>(sp->valuedouble);
+ }
+ if (cJSON_IsNumber(rp)) {
+ repeat = rp->valueint;
+ }
+ for (int i = 0; i < en_n; ++i) {
+ snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en/%.15s",
+ level, story, en_names[i]);
+ if (!add_playlist_item(items, &n, cap, path, speed, repeat)) {
+ return n;
+ }
+ }
+ en_added++;
+ }
+ }
+ if (en_added == 0) {
+ const float speeds[] = {0.8f, 0.9f, 1.0f};
+ const int repeats[] = {1, 2, 5};
+ for (int pass = 0; pass < 3; ++pass) {
+ for (int i = 0; i < en_n; ++i) {
+ snprintf(path, sizeof(path), "/english_learning/repeater/lessons/l%d/%03d/en/%.15s",
+ level, story, en_names[i]);
+ if (!add_playlist_item(items, &n, cap, path, speeds[pass], repeats[pass])) {
+ return n;
+ }
+ }
+ }
+ }
+ return n;
+}
+
+static int build_shadowing_playlist(cJSON *params, sdcard_mp3_playlist_item_t *items, int cap)
+{
+ int level = 0;
+ int story = 0;
+ const char *lesson = json_lesson_id(params);
+ if (!lesson || sscanf(lesson, "l%d.%d", &level, &story) != 2 || level < 1 || story < 0) {
+ return 0;
+ }
+ char lang[3] = "en";
+ const cJSON *language = cJSON_GetObjectItem(params, "language");
+ if (!cJSON_IsString(language) || !language->valuestring) {
+ language = cJSON_GetObjectItem(params, "lang");
+ }
+ if (cJSON_IsString(language) && language->valuestring && strcasecmp(language->valuestring, "cn") == 0) {
+ memcpy(lang, "cn", 3);
+ }
+ float speed = 0.8f;
+ const cJSON *speed_item = cJSON_GetObjectItem(params, "speed");
+ if (cJSON_IsNumber(speed_item)) {
+ speed = static_cast<float>(speed_item->valuedouble);
+ }
+ char dir[SDCARD_PATH_MAX_LEN];
+ static char names[LESSON_CLIP_MAX][LESSON_CLIP_NAME_MAX];
+ snprintf(dir, sizeof(dir), "/english_learning/shadowing/lessons/l%d/%03d/%.2s", level, story, lang);
+ int found = list_lesson_mp3s(dir, names, LESSON_CLIP_MAX);
+ int n = 0;
+ for (int i = 0; i < found && n < cap; ++i) {
+ char clip[LESSON_CLIP_NAME_MAX];
+ memcpy(clip, names[i], sizeof(clip));
+ clip[sizeof(clip) - 1] = '\0';
+ char path[SDCARD_PATH_MAX_LEN];
+ snprintf(path, sizeof(path), "/english_learning/shadowing/lessons/l%d/%03d/%.2s/%.15s",
+ level, story, lang, clip);
+ if (!add_playlist_item(items, &n, cap, path, speed, 1)) {
+ break;
+ }
+ }
+ return n;
+}
+
+static bool start_lesson_playback_locally(const char *task_id, const char *params_json)
+{
+ cJSON *params = cJSON_Parse((params_json && params_json[0]) ? params_json : "{}");
+ if (!params) {
+ return false;
+ }
+ const bool shadowing = task_id && strcmp(task_id, "shadowing") == 0;
+ const int cap = LESSON_CLIP_MAX;
+ sdcard_mp3_playlist_item_t *items = static_cast<sdcard_mp3_playlist_item_t *>(
+ heap_caps_calloc(static_cast<size_t>(cap), sizeof(*items), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
+ if (!items) {
+ cJSON_Delete(params);
+ return false;
+ }
+ int n = shadowing ? build_shadowing_playlist(params, items, cap)
+ : build_repeater_playlist(params, items, cap);
+ cJSON_Delete(params);
+ if (n < 1) {
+ heap_caps_free(items);
+ return false;
+ }
+ SDCard_PreparePlaybackAndWait();
+ Board_SetFinishTaskAfterMp3(false);
+ esp_err_t err = SDCard_Play_MP3_File_Playlist(items, n);
+ if (err == ESP_OK) {
+ ESP_LOGI(TAG, "local lesson playlist started clips=%d task=%s", n, task_id ? task_id : "");
+ Board_RememberRepeaterPlayback(items[0].path, items[0].speed, items[0].repeat, false);
+ SDCard_Show_Matching_JPG_For_MP3_Path(items[0].path);
+ } else {
+ ESP_LOGW(TAG, "local lesson playlist failed: %s", esp_err_to_name(err));
+ }
+ heap_caps_free(items);
+ return err == ESP_OK;
+}
+
+static bool is_lesson_start_phrase(const char *text)
+{
+ if (!text) {
+ return false;
+ }
+ return strstr(text, "寮�濮嬫硾鍚�") != NULL || strstr(text, "寮�濮嬭窡璇�") != NULL ||
+ strstr(text, "寮�濮嬬炕璇�") != NULL || strstr(text, "寮�濮嬩腑鑻�") != NULL ||
+ strstr(text, "寮�濮嬪璇�") != NULL;
+}
+
+bool Board_OnStartPhraseSentence(const char *text)
+{
+ if (!s_wait_start_phrase || !is_lesson_start_phrase(text)) {
+ return false;
+ }
+ s_start_phrase_tts_begun = true;
+ if (SDCard_Is_Playing()) {
+ ESP_LOGW(TAG, "stop early lesson mp3 until start phrase finishes");
+ SDCard_Stop_Play();
+ }
+ ESP_LOGI(TAG, "start phrase heard, wait until it finishes");
+ return true;
+}
+
+static void begin_scheduled_talk_after_phrase(void)
+{
+ s_wait_start_phrase = false;
+ s_start_phrase_tts_begun = false;
+ s_listen_after_phrase = false;
+ auto& app = Application::GetInstance();
+ if (app.GetDeviceState() == kDeviceStateSpeaking) {
+ s_wait_start_phrase = true;
+ s_start_phrase_tts_begun = true;
+ s_listen_after_phrase = true;
+ return;
+ }
+ ESP_LOGI(TAG, "start phrase done, open listening");
+ app.ReleaseHoldIdle();
+ app.StartListeningAfterPrompt();
+}
+
+void Board_FlushPendingLessonPlayback(void)
+{
+ if (!s_wait_start_phrase || !s_start_phrase_tts_begun) {
+ return;
+ }
+ if (s_listen_after_phrase) {
+ begin_scheduled_talk_after_phrase();
+ return;
+ }
+ s_wait_start_phrase = false;
+ s_start_phrase_tts_begun = false;
+ if (strcmp(s_current_task, "repeater") != 0 && strcmp(s_current_task, "shadowing") != 0) {
+ return;
+ }
+ if (Application::GetInstance().GetDeviceState() == kDeviceStateSpeaking) {
+ s_wait_start_phrase = true;
+ s_start_phrase_tts_begun = true;
+ return;
+ }
+ ESP_LOGI(TAG, "start phrase done, play lesson");
+ start_lesson_playback_locally(s_current_task, s_current_params);
+}
+
+void Board_NoteLessonStartDetectSent(void)
+{
+ if (!s_wait_start_phrase || s_start_detect_sent) {
+ return;
+ }
+ s_start_detect_sent = true;
+ s_wait_start_us = esp_timer_get_time();
+ ESP_LOGI(TAG, "start detect sent, wait for phrase");
+}
+
+void Board_TrySendLessonStartDetect(void)
+{
+ if (!s_wait_start_phrase || s_start_detect_sent || s_start_detect_text.empty()) {
+ return;
+ }
+ auto& app = Application::GetInstance();
+ auto state = app.GetDeviceState();
+ if (state == kDeviceStateSpeaking || state == kDeviceStateConnecting) {
+ return;
+ }
+ if (app.IsAudioChannelConnected()) {
+ if (app.SendListenDetectNow(s_start_detect_text)) {
+ Board_NoteLessonStartDetectSent();
+ }
+ return;
+ }
+ if (state == kDeviceStateIdle && Board::GetInstance().IsNetworkReady()) {
+ app.SendListenDetect(s_start_detect_text, true);
+ }
+}
+
+void Board_PollPendingLessonPlayback(void)
+{
+ if (!s_wait_start_phrase) {
+ return;
+ }
+ auto state = Application::GetInstance().GetDeviceState();
+ if (!s_start_phrase_tts_begun) {
+ if (!s_start_detect_sent) {
+ Board_TrySendLessonStartDetect();
+ return;
+ }
+ const int64_t elapsed_ms = (esp_timer_get_time() - s_wait_start_us) / 1000;
+ if (elapsed_ms >= 15000 && state != kDeviceStateSpeaking && state != kDeviceStateConnecting) {
+ if (s_listen_after_phrase) {
+ ESP_LOGW(TAG, "start phrase timeout, open listening");
+ s_start_phrase_tts_begun = true;
+ begin_scheduled_talk_after_phrase();
+ } else {
+ ESP_LOGW(TAG, "start phrase timeout, play lesson locally");
+ s_start_phrase_tts_begun = true;
+ Board_FlushPendingLessonPlayback();
+ }
+ }
+ return;
+ }
+ if (state == kDeviceStateSpeaking || state == kDeviceStateConnecting) {
+ return;
+ }
+ Board_FlushPendingLessonPlayback();
+}
+
+static void return_to_chat_locally(const char *reason, const char *detect)
+{
+ ESP_LOGW(TAG, "%s, return to chat locally", reason ? reason : "skill event not sent");
+ copy_id(s_current_app, sizeof(s_current_app), "chat", "chat");
+ copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation");
+ copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat");
+ s_current_priority = 0;
+ Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
+ Application::GetInstance().ReconnectAfterLesson(detect ? detect : "");
}
static void dispatch_skill(const char *skill, const char *app_id, const char *task_id,
@@ -516,6 +1202,16 @@
cJSON_Delete(params);
}
}
+ const bool server_first = Board::GetInstance().IsNetworkReady() && source &&
+ (strcmp(source, "voice") == 0 || strcmp(source, "mqtt_immediate") == 0 ||
+ strcmp(source, "schedule") == 0);
+ if (server_first) {
+ if (name[0]) {
+ Board_RememberRepeaterPlayback(name, speed, repeat < 1 ? 1 : repeat, power_off_after);
+ }
+ notify_server(app_id, task_id, source, true);
+ return;
+ }
if (!Board_ResolveRepeaterPlayback(name, sizeof(name), &speed, &repeat, &power_off_after)) {
ESP_LOGW(TAG, "self.mp3.play has no last lesson and no SD MP3");
notify_server(app_id, task_id, source, true);
@@ -526,12 +1222,6 @@
}
speed = SDCard_Clamp_MP3_Speed(speed);
Board_SetFinishTaskAfterMp3(true);
- // 鏈夌綉鐨勮闊冲叆鍙o細鍏堣 Server 鎾姤杩涘満璇嶏紝闊抽鐢� MCP 鍦� TTS 缁撴潫鍚庡啀鎾��
- if (source && strcmp(source, "voice") == 0 && Board::GetInstance().IsNetworkReady()) {
- Board_RememberRepeaterPlayback(name, speed, repeat, power_off_after);
- notify_server(app_id, task_id, source, true);
- return;
- }
esp_err_t err = SDCard_Play_MP3_With_Speed_Repeat(name, speed, repeat, power_off_after);
if (err != ESP_OK) {
ESP_LOGW(TAG, "self.mp3.play failed: %s", esp_err_to_name(err));
@@ -555,19 +1245,40 @@
return ESP_ERR_INVALID_ARG;
}
const char *src = (source && source[0]) ? source : "mqtt_immediate";
- const char *tid = (task_id && task_id[0]) ? task_id : NULL;
- const app_task_skill_map_t *spec = find_task_spec(app_id, tid);
- if (!spec) {
- ESP_LOGW(TAG, "unknown app/task: %s/%s", app_id, tid ? tid : "");
- return ESP_ERR_NOT_FOUND;
+ char resolved_tid[APP_TASK_ID_MAX];
+ copy_id(resolved_tid, sizeof(resolved_tid), task_id, "conversation");
+ const app_task_skill_map_t *spec = find_task_spec(app_id, resolved_tid);
+ if (spec) {
+ copy_id(resolved_tid, sizeof(resolved_tid), spec->task_id, resolved_tid);
+ } else if (!is_safe_task_id(app_id) || !is_safe_task_id(resolved_tid)) {
+ ESP_LOGW(TAG, "invalid app/task id: %s/%s", app_id, resolved_tid);
+ return ESP_ERR_INVALID_ARG;
+ } else {
+ ESP_LOGI(TAG, "server-defined app_task %s/%s", app_id, resolved_tid);
}
- tid = spec->task_id;
+ const char *tid = resolved_tid;
int new_priority = Board_AppTask_SourcePriority(src);
- if (new_priority < s_current_priority) {
+ if (strcmp(src, "mqtt_immediate") != 0 && new_priority < s_current_priority) {
ESP_LOGW(TAG, "reject %s/%s source=%s prio %d < current %d",
app_id, tid, src, new_priority, s_current_priority);
return ESP_ERR_INVALID_STATE;
+ }
+
+ if (s_wait_start_phrase && strcmp(src, "schedule") == 0 &&
+ strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0) {
+ ESP_LOGI(TAG, "schedule %s/%s already waiting for start phrase", app_id, tid);
+ return ESP_OK;
+ }
+
+ if (strcmp(src, "schedule") == 0 &&
+ (strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0)) {
+ const char *raw_params = (params_json && params_json[0]) ? params_json : "{}";
+ if (!lesson_files_ready(tid, raw_params)) {
+ ESP_LOGW(TAG, "schedule %s/%s files not on SD yet", app_id, tid);
+ show_lesson_sync_alert();
+ return ESP_ERR_NOT_FOUND;
+ }
}
if (strcmp(s_current_app, app_id) == 0 && strcmp(s_current_task, tid) == 0 &&
@@ -586,16 +1297,62 @@
copy_id(s_current_app, sizeof(s_current_app), app_id, "chat");
copy_id(s_current_task, sizeof(s_current_task), tid, "conversation");
copy_id(s_current_source, sizeof(s_current_source), src, "default_chat");
+
+ char loaded_params[APP_TASK_JSON_MAX];
+ loaded_params[0] = '\0';
+ const char *raw = (params_json && params_json[0]) ? params_json : "{}";
+ const bool lesson_task = strcmp(tid, "repeater") == 0 || strcmp(tid, "shadowing") == 0;
+ if (strcmp(src, "voice") == 0 && lesson_task && !params_has_lesson_id(raw)) {
+ if (Board_LoadLastLessonTask(tid, loaded_params, sizeof(loaded_params))) {
+ raw = loaded_params;
+ ESP_LOGI(TAG, "voice %s uses last dispatched lesson", tid);
+ }
+ }
+ if (lesson_task && compact_last_lesson_json(raw, s_current_params, sizeof(s_current_params))) {
+ Board_RememberLastLessonTask(tid, s_current_params);
+ } else {
+ strlcpy(s_current_params, raw, sizeof(s_current_params));
+ if (lesson_task) {
+ Board_RememberLastLessonTask(tid, s_current_params);
+ }
+ }
+ const char *effective = s_current_params;
s_current_priority = new_priority;
- Board_SavePendingTask(app_id, tid, params_json, src, NULL);
+ s_wait_start_phrase = false;
+ s_start_phrase_tts_begun = false;
+ s_listen_after_phrase = false;
+ Board_SavePendingTask(app_id, tid, effective, src, NULL);
ESP_LOGI(TAG, "start app_task %s/%s source=%s", app_id, tid, src);
- if (!spec->skills[0]) {
- dispatch_skill(NULL, app_id, tid, params_json, src);
+ if (strcmp(src, "schedule") == 0 && is_schedule_talk_task(tid)) {
+ s_wait_start_phrase = true;
+ s_start_phrase_tts_begun = false;
+ s_start_detect_sent = false;
+ s_listen_after_phrase = true;
+ s_wait_start_us = esp_timer_get_time();
+ Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
+ notify_server(app_id, tid, src, true, true, false, true);
+ return ESP_OK;
+ }
+ if (lesson_task && !lesson_files_ready(tid, effective)) {
+ ESP_LOGW(TAG, "lesson files missing on SD, wait for sync %s/%s", app_id, tid);
+ show_lesson_sync_alert();
+ } else if (lesson_task && lesson_files_ready(tid, effective)) {
+ s_wait_start_phrase = true;
+ s_start_phrase_tts_begun = false;
+ s_start_detect_sent = false;
+ s_wait_start_us = esp_timer_get_time();
+ Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
+ notify_server(app_id, tid, src, false, true, true);
+ return ESP_OK;
+ }
+ Board_Schedule_OnTaskStarted(app_id, tid, src, effective);
+ if (!spec || !spec->skills[0]) {
+ dispatch_skill(NULL, app_id, tid, effective, src);
return ESP_OK;
}
for (int i = 0; i < 4 && spec->skills[i]; ++i) {
- dispatch_skill(spec->skills[i], app_id, tid, params_json, src);
+ dispatch_skill(spec->skills[i], app_id, tid, effective, src);
}
return ESP_OK;
}
@@ -614,7 +1371,7 @@
copy_id(task_id, sizeof(task_id), s_current_task, "conversation");
const bool shadowing = strcmp(task_id, "shadowing") == 0;
const bool repeater = strcmp(task_id, "repeater") == 0;
- // 澶嶈娓呭崟鏄惁缁撴潫鐢� Server 鍐冲畾銆傝澶囦笉瑕佸湪姝ゅ啓 NVS 鍒囧洖 chat銆�
+ // WS 鍦ㄧ嚎鏃舵竻鍗曠粨鏉熺敱 Server 鍒囧洖 chat銆俉S 宸叉柇鍒欎笅闈㈡湰鍦板厹搴曪紝閬垮厤涓�鐩村仠鍦ㄦ硾鍚��
const bool end_task = !shadowing && !repeater &&
task_owns_play_audio(app_id, task_id) && s_finish_task_after_mp3;
@@ -632,15 +1389,26 @@
const bool open_mic = !stopped && shadowing;
// 鍚獥浠庤鏂囦竴鍋滃氨寮�濮嬨�備笉瑕佺瓑寮�楹�/AFE 鍒濆鍖栧悗鍐嶄笂鎶� completed銆�
if (!Application::GetInstance().SendListenDetectNow(detect)) {
+ // WS 宸叉柇锛氶噴鏀� AFE 鍚庨噸杩烇紝鎶� completed 琛ュ彂缁� Server 鎾粨鏉熻銆�
+ if (repeater && !stopped) {
+ return_to_chat_locally("repeater completed not sent", detect);
+ Board_Schedule_OnNaturalEnd();
+ return;
+ }
Application::GetInstance().SendListenDetect(detect, false);
}
if (open_mic) {
Application::GetInstance().GetAudioService().ResetDecoder();
Application::GetInstance().ResumeListeningAfterMp3Clip();
} else if (repeater && !stopped) {
- // 娓呭崟宸叉挱瀹岋紝椹笂瑕佹挱銆屽璇荤粨鏉熴�嶏細淇濇寔鍔熸斁锛屼笉瑕佸啀 ResetDecoder銆�
+ // 涓庤窡璇绘竻鍗曠粨鏉熺浉鍚岋細鍏� AFE/鍞ら啋锛岀瓑 Server 鎾�屾硾鍚粨鏉熴�嶅啀鍥炲璇濄��
+ Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
+ Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
+ Board_Schedule_OnNaturalEnd();
+ } else if (repeater && stopped) {
+ Board_Schedule_OnUserExit();
} else if (!repeater) {
Application::GetInstance().GetAudioService().ResetDecoder();
}
@@ -682,48 +1450,36 @@
snprintf(detect, sizeof(detect), "[skill_event]audio.playlist_completed|%s|%s",
app_id, task_id);
if (!Application::GetInstance().SendListenDetectNow(detect)) {
- ESP_LOGW(TAG, "playlist_completed not sent, return to chat locally");
- copy_id(s_current_app, sizeof(s_current_app), "chat", "chat");
- copy_id(s_current_task, sizeof(s_current_task), "conversation", "conversation");
- copy_id(s_current_source, sizeof(s_current_source), "default_chat", "default_chat");
- s_current_priority = 0;
- Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
- auto &app = Application::GetInstance();
- app.GetAudioService().SetAudioPowerInhibit(false);
- app.GetAudioService().EnableVoiceProcessing(false);
- app.GetAudioService().EnableWakeWordDetection(true);
- if (app.GetDeviceState() == kDeviceStateListening ||
- app.GetDeviceState() == kDeviceStateConnecting) {
- app.SetDeviceState(kDeviceStateIdle);
- }
+ return_to_chat_locally("playlist_completed not sent", detect);
+ Board_Schedule_OnNaturalEnd();
return;
}
+ Application::GetInstance().GetAudioService().EnableVoiceProcessing(false);
+ Application::GetInstance().GetAudioService().EnableWakeWordDetection(false);
Application::GetInstance().GetAudioService().KeepCodecReadyForPlayback();
Application::GetInstance().GetAudioService().SetAudioPowerInhibit(true);
+ Board_Schedule_OnNaturalEnd();
});
}
void Board_ResolveBootOrRestore(bool from_alarm_wake)
{
- char app_id[APP_TASK_ID_MAX] = "chat";
- char task_id[APP_TASK_ID_MAX] = "conversation";
- char source[APP_TASK_SOURCE_MAX] = "default_chat";
- char params[APP_TASK_JSON_MAX] = "{}";
- bool has_pending = load_pending_fields(app_id, sizeof(app_id), task_id, sizeof(task_id),
- source, sizeof(source), params, sizeof(params));
- if (!has_pending) {
- Board_SavePendingTask("chat", "conversation", "{}", "default_chat", NULL);
- Board_StartAppTask("chat", "conversation", "{}", "default_chat");
- return;
+ if (!from_alarm_wake) {
+ Board_Schedule_Reload();
+ Board_Schedule_Recompute();
}
-
- const bool scheduled = strcmp(source, "schedule") == 0 || strcmp(source, "mqtt_immediate") == 0;
- const bool offline = !Board::GetInstance().IsNetworkReady();
- if (!is_chat_task(app_id, task_id) && (from_alarm_wake || scheduled || offline)) {
- ESP_LOGI(TAG, "restore pending %s/%s source=%s offline=%d",
- app_id, task_id, source, offline ? 1 : 0);
- Board_StartAppTask(app_id, task_id, params, source);
- return;
+ const int window = 180;
+ if (Board_Schedule_IsDueNow(window)) {
+ char app_id[APP_TASK_ID_MAX];
+ char task_id[APP_TASK_ID_MAX];
+ char params[APP_TASK_JSON_MAX] = "{}";
+ if (Board_Schedule_LoadDueTask(app_id, sizeof(app_id), task_id, sizeof(task_id),
+ params, sizeof(params))) {
+ ESP_LOGI(TAG, "boot/restore due schedule %s/%s alarm=%d",
+ app_id, task_id, from_alarm_wake ? 1 : 0);
+ Board_StartAppTask(app_id, task_id, params, "schedule");
+ return;
+ }
}
Board_StartAppTask("chat", "conversation", "{}", "default_chat");
@@ -736,7 +1492,7 @@
return;
}
if (!Board::GetInstance().IsNetworkReady()) {
- ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 澶嶈鏈� to play locally");
+ ESP_LOGW(TAG, "voice_entry skipped, network not ready; say 娉涘惉 to play locally");
return;
}
Application::GetInstance().SendListenDetect("[voice_entry]", true);
@@ -754,6 +1510,19 @@
}
if (Board_Is_Chat_Return_Command(command)) {
s_current_priority = 0;
+ auto &app = Application::GetInstance();
+ if (is_chat_task(s_current_app, s_current_task) && !app.IsAudioChannelConnected()) {
+ // 璇炬枃缁撴潫宸叉湰鍦板洖 chat銆傞�氶亾宸叉柇鏃跺啀鍘� handshake 浼氬崱銆岃繛鎺ヤ腑銆嶅苟鍏虫帀鍞ら啋銆�
+ app.Schedule([]() {
+ auto &inner = Application::GetInstance();
+ inner.AnnounceReturnedToChat();
+ inner.GetAudioService().EnableWakeWordDetection(true);
+ if (inner.GetDeviceState() == kDeviceStateConnecting) {
+ inner.SetDeviceState(kDeviceStateIdle);
+ }
+ });
+ return true;
+ }
Board_StartAppTask("chat", "conversation", "{}", "voice");
return true;
}
@@ -766,7 +1535,7 @@
}
if (strcmp(command, OFFLINE_REPEATER_WORD) == 0 ||
strcmp(command, OFFLINE_START_REPEATER_WORD) == 0 ||
- strstr(command, "寮�濮嬪璇�") || strstr(command, "澶嶈鏈�")) {
+ strstr(command, OFFLINE_START_REPEATER_WORD) || strstr(command, "鎵撳紑娉涘惉")) {
if (s_current_priority >= 2) {
return true;
}
--
Gitblit v1.9.1