-
-
-
-
+
-
-
-
- {{ tm('configSelection.normalConfig') }}
-
-
- {{ tm('configSelection.systemConfig') }}
-
-
-
+
+
+
+ {{ tm('messages.unsavedChangesNotice') }}
+
+
+
@@ -35,6 +45,7 @@
@@ -180,6 +191,10 @@
+
+
+
+
@@ -190,6 +205,12 @@ import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import StandaloneChat from '@/components/chat/StandaloneChat.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { useI18n, useModuleI18n } from '@/i18n/composables';
+import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
+import {
+ askForConfirmation as askForConfirmationDialog,
+ useConfirmDialog
+} from '@/utils/confirmDialog';
+import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue';
export default {
name: 'ConfigPage',
@@ -197,7 +218,8 @@ export default {
AstrBotCoreConfigWrapper,
VueMonacoEditor,
WaitingForRestart,
- StandaloneChat
+ StandaloneChat,
+ UnsavedChangesConfirmDialog
},
props: {
initialConfigId: {
@@ -208,13 +230,49 @@ export default {
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/config');
+ const confirmDialog = useConfirmDialog();
return {
t,
- tm
+ tm,
+ confirmDialog
};
},
+// 检查未保存的更改
+ async beforeRouteLeave(to, from, next) {
+ if (this.hasUnsavedChanges) {
+ const confirmed = await this.$refs.unsavedChangesDialog?.open({
+ title: this.tm('unsavedChangesWarning.dialogTitle'),
+ message: this.tm('unsavedChangesWarning.leavePage'),
+ confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
+ cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
+ closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
+ });
+ // 关闭弹窗不跳转
+ if (confirmed === 'close') {
+ next(false);
+ } else if (confirmed) {
+ const result = await this.updateConfig();
+ if (this.isSystemConfig) {
+ next(false);
+ } else {
+ if (result?.success) {
+ await new Promise(resolve => setTimeout(resolve, 800));
+ next();
+ } else {
+ next(false);
+ }
+ }
+ } else {
+ this.hasUnsavedChanges = false;
+ next();
+ }
+ } else {
+ next();
+ }
+ },
+
computed: {
messages() {
return {
@@ -225,6 +283,11 @@ export default {
configApplyError: this.tm('messages.configApplyError')
};
},
+ // 检查配置是否变化
+ configHasChanges() {
+ if (!this.originalConfigData || !this.config_data) return false;
+ return JSON.stringify(this.originalConfigData) !== JSON.stringify(this.config_data);
+ },
configInfoNameList() {
return this.configInfoList.map(info => info.name);
},
@@ -240,11 +303,28 @@ export default {
});
return items;
},
+ hasUnsavedChanges() {
+ if (!this.fetched) {
+ return false;
+ }
+ return this.getConfigSnapshot(this.config_data) !== this.lastSavedConfigSnapshot;
+ }
},
watch: {
config_data_str(val) {
this.config_data_has_changed = true;
},
+ config_data: {
+ deep: true,
+ handler() {
+ if (this.fetched) {
+ this.hasUnsavedChanges = this.configHasChanges;
+ }
+ }
+ },
+ async '$route.fullPath'(newVal) {
+ await this.syncConfigTypeFromHash(newVal);
+ },
initialConfigId(newVal) {
if (!newVal) {
return;
@@ -271,15 +351,18 @@ export default {
save_message: "",
save_message_success: "",
configContentKey: 0,
+ lastSavedConfigSnapshot: '',
// 配置类型切换
configType: 'normal', // 'normal' 或 'system'
+ configSearchKeyword: '',
// 系统配置开关
isSystemConfig: false,
// 多配置文件管理
selectedConfigID: null, // 用于存储当前选中的配置项信息
+ currentConfigId: null, // 跟踪当前正在编辑的配置id
configInfoList: [],
configFormData: {
name: '',
@@ -289,15 +372,72 @@ export default {
// 测试聊天
testChatDrawer: false,
testConfigId: null,
+
+ // 未保存的更改状态
+ hasUnsavedChanges: false,
+ // 存储原始配置
+ originalConfigData: null,
}
},
mounted() {
+ const hashConfigType = this.extractConfigTypeFromHash(
+ this.$route?.fullPath || ''
+ );
+ this.configType = hashConfigType || 'normal';
+ this.isSystemConfig = this.configType === 'system';
+
const targetConfigId = this.initialConfigId || 'default';
this.getConfigInfoList(targetConfigId);
// 初始化配置类型状态
this.configType = this.isSystemConfig ? 'system' : 'normal';
+
+ // 监听语言切换事件,重新加载配置以获取插件的 i18n 数据
+ window.addEventListener('astrbot-locale-changed', this.handleLocaleChange);
+
+ // 保存初始配置
+ this.$watch('config_data', (newVal) => {
+ if (!this.originalConfigData && newVal) {
+ this.originalConfigData = JSON.parse(JSON.stringify(newVal));
+ }
+ }, { immediate: false, deep: true });
+ },
+
+ beforeUnmount() {
+ // 移除语言切换事件监听器
+ window.removeEventListener('astrbot-locale-changed', this.handleLocaleChange);
},
methods: {
+ // 处理语言切换事件,重新加载配置以获取插件的 i18n 数据
+ handleLocaleChange() {
+ // 重新加载当前配置
+ if (this.selectedConfigID) {
+ this.getConfig(this.selectedConfigID);
+ } else if (this.isSystemConfig) {
+ this.getConfig();
+ }
+ },
+
+ },
+ methods: {
+ extractConfigTypeFromHash(hash) {
+ const rawHash = String(hash || '');
+ const lastHashIndex = rawHash.lastIndexOf('#');
+ if (lastHashIndex === -1) {
+ return null;
+ }
+ const cleanHash = rawHash.slice(lastHashIndex + 1);
+ return cleanHash === 'system' || cleanHash === 'normal' ? cleanHash : null;
+ },
+ async syncConfigTypeFromHash(hash) {
+ const configType = this.extractConfigTypeFromHash(hash);
+ if (!configType || configType === this.configType) {
+ return false;
+ }
+
+ this.configType = configType;
+ await this.onConfigTypeToggle();
+ return true;
+ },
getConfigInfoList(abconf_id) {
// 获取配置列表
axios.get('/api/config/abconfs').then((res) => {
@@ -308,6 +448,7 @@ export default {
for (let i = 0; i < this.configInfoList.length; i++) {
if (this.configInfoList[i].id === abconf_id) {
this.selectedConfigID = this.configInfoList[i].id;
+ this.currentConfigId = this.configInfoList[i].id;
this.getConfig(abconf_id);
matched = true;
break;
@@ -317,6 +458,7 @@ export default {
if (!matched && this.configInfoList.length) {
// 当找不到目标配置时,默认展示列表中的第一个配置
this.selectedConfigID = this.configInfoList[0].id;
+ this.currentConfigId = this.configInfoList[0].id;
this.getConfig(this.selectedConfigID);
}
}
@@ -340,9 +482,18 @@ export default {
params: params
}).then((res) => {
this.config_data = res.data.data.config;
+ this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.fetched = true
this.metadata = res.data.data.metadata;
this.configContentKey += 1;
+ // 获取配置后更新
+ this.$nextTick(() => {
+ this.originalConfigData = JSON.parse(JSON.stringify(this.config_data));
+ this.hasUnsavedChanges = false;
+ if (!this.isSystemConfig) {
+ this.currentConfigId = abconf_id || this.selectedConfigID;
+ }
+ });
}).catch((err) => {
this.save_message = this.messages.loadError;
this.save_message_snack = true;
@@ -362,28 +513,37 @@ export default {
postData.conf_id = this.selectedConfigID;
}
- axios.post('/api/config/astrbot/update', postData).then((res) => {
+ return axios.post('/api/config/astrbot/update', postData).then((res) => {
if (res.data.status === "ok") {
+ this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.save_message = res.data.message || this.messages.saveSuccess;
this.save_message_snack = true;
this.save_message_success = "success";
+ this.onConfigSaved();
if (this.isSystemConfig) {
- axios.post('/api/stat/restart-core').then(() => {
- this.$refs.wfr.check();
- })
+ restartAstrBotRuntime(this.$refs.wfr).catch(() => {})
}
+ return { success: true };
} else {
this.save_message = res.data.message || this.messages.saveError;
this.save_message_snack = true;
this.save_message_success = "error";
+ return { success: false };
}
}).catch((err) => {
this.save_message = this.messages.saveError;
this.save_message_snack = true;
this.save_message_success = "error";
+ return { success: false };
});
},
+ // 重置未保存状态
+ onConfigSaved() {
+ this.hasUnsavedChanges = false;
+ this.originalConfigData = JSON.parse(JSON.stringify(this.config_data));
+ },
+
configToString() {
this.config_data_str = JSON.stringify(this.config_data, null, 2);
this.config_data_has_changed = false;
@@ -423,15 +583,53 @@ export default {
this.save_message_success = "error";
});
},
- onConfigSelect(value) {
+ async onConfigSelect(value) {
if (value === '_%manage%_') {
this.configManageDialog = true;
// 重置选择到之前的值
this.$nextTick(() => {
this.selectedConfigID = this.selectedConfigInfo.id || 'default';
+ this.getConfig(this.selectedConfigID);
});
} else {
- this.getConfig(value);
+ // 检查是否有未保存的更改
+ if (this.hasUnsavedChanges) {
+ // 获取之前正在编辑的配置id
+ const prevConfigId = this.isSystemConfig ? 'default' : (this.currentConfigId || this.selectedConfigID || 'default');
+ const message = this.tm('unsavedChangesWarning.switchConfig');
+ const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({
+ title: this.tm('unsavedChangesWarning.dialogTitle'),
+ message: message,
+ confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
+ cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
+ closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
+ });
+ // 关闭弹窗不切换
+ if (saveAndSwitch === 'close') {
+ return;
+ }
+ if (saveAndSwitch) {
+ // 设置临时变量保存切换后的id
+ const currentSelectedId = this.selectedConfigID;
+ // 把id设置回切换前的用于保存上一次的配置,保存完后恢复id为切换后的
+ this.selectedConfigID = prevConfigId;
+ const result = await this.updateConfig();
+ this.selectedConfigID = currentSelectedId;
+ if (result?.success) {
+ this.selectedConfigID = value;
+ this.getConfig(value);
+ }
+ return;
+ } else {
+ // 取消保存并切换配置
+ this.selectedConfigID = value;
+ this.getConfig(value);
+ }
+ } else {
+ // 无未保存更改直接切换
+ this.selectedConfigID = value;
+ this.getConfig(value);
+ }
}
},
startCreateConfig() {
@@ -473,8 +671,9 @@ export default {
this.createNewConfig();
}
},
- confirmDeleteConfig(config) {
- if (confirm(this.tm('configManagement.confirmDelete').replace('{name}', config.name))) {
+ async confirmDeleteConfig(config) {
+ const message = this.tm('configManagement.confirmDelete').replace('{name}', config.name);
+ if (await askForConfirmationDialog(message, this.confirmDialog)) {
this.deleteConfig(config.id);
}
},
@@ -524,7 +723,34 @@ export default {
this.save_message_success = "error";
});
},
- onConfigTypeToggle() {
+ async onConfigTypeToggle() {
+ // 检查是否有未保存的更改
+ if (this.hasUnsavedChanges) {
+ const message = this.tm('unsavedChangesWarning.leavePage');
+ const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({
+ title: this.tm('unsavedChangesWarning.dialogTitle'),
+ message: message,
+ confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
+ cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
+ closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
+ });
+ // 关闭弹窗
+ if (saveAndSwitch === 'close') {
+ // 恢复路由
+ const originalHash = this.isSystemConfig ? '#system' : '#normal';
+ this.$router.replace('/config' + originalHash);
+ this.configType = this.isSystemConfig ? 'system' : 'normal';
+ return;
+ }
+ if (saveAndSwitch) {
+ await this.updateConfig();
+ // 系统配置保存后不跳转
+ if (this.isSystemConfig) {
+ this.$router.replace('/config#system');
+ return;
+ }
+ }
+ }
this.isSystemConfig = this.configType === 'system';
this.fetched = false; // 重置加载状态
@@ -544,19 +770,7 @@ export default {
// 保持向后兼容性,更新 configType
this.configType = this.isSystemConfig ? 'system' : 'normal';
- this.fetched = false; // 重置加载状态
-
- if (this.isSystemConfig) {
- // 切换到系统配置
- this.getConfig();
- } else {
- // 切换回普通配置,如果有选中的配置文件则加载,否则加载default
- if (this.selectedConfigID) {
- this.getConfig(this.selectedConfigID);
- } else {
- this.getConfigInfoList("default");
- }
- }
+ this.onConfigTypeToggle();
},
openTestChat() {
if (!this.selectedConfigID) {
@@ -571,6 +785,9 @@ export default {
closeTestChat() {
this.testChatDrawer = false;
this.testConfigId = null;
+ },
+ getConfigSnapshot(config) {
+ return JSON.stringify(config ?? {});
}
},
}
@@ -582,6 +799,26 @@ export default {
text-transform: none !important;
}
+.unsaved-changes-banner {
+ border-radius: 8px;
+}
+
+.v-theme--light .unsaved-changes-banner {
+ background-color: #f1f4f9 !important;
+}
+
+.v-theme--dark .unsaved-changes-banner {
+ background-color: #2d2d2d !important;
+}
+
+.unsaved-changes-banner-wrap {
+ position: sticky;
+ top: calc(var(--v-layout-top, 64px));
+ z-index: 20;
+ width: 100%;
+ margin-bottom: 6px;
+}
+
/* 按钮切换样式优化 */
.v-btn-toggle .v-btn {
transition: all 0.3s ease !important;
@@ -629,6 +866,21 @@ export default {
.config-panel {
width: 100%;
}
+
+ .config-toolbar {
+ padding-right: 0 !important;
+ }
+
+ .config-toolbar-controls {
+ width: 100%;
+ flex-wrap: wrap;
+ }
+
+ .config-select,
+ .config-search-input {
+ width: 100%;
+ min-width: 0 !important;
+ }
}
/* 测试聊天抽屉样式 */
@@ -658,4 +910,4 @@ export default {
padding: 0;
border-radius: 0 0 16px 16px;
}
-
\ No newline at end of file
+
diff --git a/dashboard/src/views/ConversationPage.vue b/dashboard/src/views/ConversationPage.vue
index 2a615b294..3d25855f3 100644
--- a/dashboard/src/views/ConversationPage.vue
+++ b/dashboard/src/views/ConversationPage.vue
@@ -333,6 +333,10 @@ import { useCommonStore } from '@/stores/common';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import MessageList from '@/components/chat/MessageList.vue';
+import {
+ askForConfirmation as askForConfirmationDialog,
+ useConfirmDialog
+} from '@/utils/confirmDialog';
export default {
name: 'ConversationPage',
@@ -345,12 +349,14 @@ export default {
const { t, locale } = useI18n();
const { tm } = useModuleI18n('features/conversation');
const customizerStore = useCustomizerStore();
+ const confirmDialog = useConfirmDialog();
return {
t,
tm,
locale,
- customizerStore
+ customizerStore,
+ confirmDialog
};
},
@@ -744,9 +750,9 @@ export default {
},
// 关闭对话历史对话框
- closeHistoryDialog() {
+ async closeHistoryDialog() {
if (this.isEditingHistory) {
- if (confirm(this.tm('dialogs.view.confirmClose'))) {
+ if (await askForConfirmationDialog(this.tm('dialogs.view.confirmClose'), this.confirmDialog)) {
this.dialogView = false;
}
} else {
@@ -1115,7 +1121,7 @@ export default {
.text-truncate {
display: inline-block;
- max-width: 100px;
+ /* max-width: 100px; */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -1133,4 +1139,4 @@ export default {
transform: translateY(0);
}
}
-
\ No newline at end of file
+
diff --git a/dashboard/src/views/CronJobPage.vue b/dashboard/src/views/CronJobPage.vue
index 1bc72b508..4d0af43ec 100644
--- a/dashboard/src/views/CronJobPage.vue
+++ b/dashboard/src/views/CronJobPage.vue
@@ -55,11 +55,12 @@
{{ formatTime(item.last_run_at) }}
{{ item.note || tm('table.notAvailable') }}
-
+
- {{ tm('actions.delete')
- }}
+ class="mt-0" @change="toggleJob(item)" />
+
+ {{ tm('actions.delete') }}
+
diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue
index c62637e3c..026a08f39 100644
--- a/dashboard/src/views/ExtensionPage.vue
+++ b/dashboard/src/views/ExtensionPage.vue
@@ -1,5 +1,4 @@
@@ -1105,478 +159,16 @@ watch(activeTab, (newTab) => {
-
-
-
-
-
- mdi-puzzle
- {{ tm("tabs.installedPlugins") }}
-
-
- mdi-store
- {{ tm("tabs.market") }}
-
-
- mdi-server-network
- {{ tm("tabs.installedMcpServers") }}
-
-
- mdi-lightning-bolt
- {{ tm("tabs.skills") }}
-
-
- mdi-wrench
- {{ tm("tabs.handlersOperation") }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- mdi-view-grid
-
-
- mdi-view-list
-
-
-
-
- {{
- showReserved ? "mdi-eye-off" : "mdi-eye"
- }}
- {{
- showReserved
- ? tm("buttons.hideSystemPlugins")
- : tm("buttons.showSystemPlugins")
- }}
-
-
-
- mdi-update
- {{ tm("buttons.updateAll") }}
-
-
-
- mdi-plus
- {{ tm("buttons.install") }}
-
-
-
-
-
-
- mdi-alert-circle
-
-
-
-
-
- mdi-alert-circle
- {{ tm("dialogs.error.title") }}
-
-
-
- {{ extension_data.message }}
-
-
- {{ tm("dialogs.error.checkConsole") }}
-
-
-
-
- {{ tm("buttons.close") }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ tm("status.loading") }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{
- item.display_name && item.display_name.length
- ? item.display_name
- : item.name
- }}
-
-
- {{ item.name }}
-
-
- {{ tm("status.system") }}
-
-
-
-
-
-
-
- {{ item.desc }}
-
-
-
-
-
- {{ item.version }}
- mdi-alert
-
- {{ tm("messages.hasUpdate") }}
- {{ item.online_version }}
-
-
-
-
-
- {{ item.author }}
-
-
-
-
- {{
- item.activated
- ? tm("status.enabled")
- : tm("status.disabled")
- }}
-
-
-
-
-
-
-
- mdi-play
- {{
- tm("tooltips.enable")
- }}
-
-
- mdi-pause
- {{
- tm("tooltips.disable")
- }}
-
-
-
- mdi-refresh
- {{
- tm("tooltips.reload")
- }}
-
-
-
- mdi-cog
- {{
- tm("tooltips.configure")
- }}
-
-
-
- mdi-information
- {{
- tm("tooltips.viewInfo")
- }}
-
-
-
- mdi-book-open-page-variant
- {{
- tm("tooltips.viewDocs")
- }}
-
-
-
- mdi-update
- {{
- tm("tooltips.update")
- }}
-
-
-
- mdi-delete
- {{
- tm("tooltips.uninstall")
- }}
-
-
-
-
-
-
-
-
mdi-puzzle-outline
-
- {{ tm("empty.noPlugins") }}
-
-
- {{ tm("empty.noPluginsDesc") }}
-
-
-
-
-
-
-
-
-
-
-
- mdi-puzzle-outline
- {{ tm("empty.noPlugins") }}
-
- {{ tm("empty.noPluginsDesc") }}
-
-
-
-
-
-
- uninstallExtension(ext.name, options)
- "
- @update="updateExtension(extension.name)"
- @reload="reloadPlugin(extension.name)"
- @toggle-activation="
- extension.activated
- ? pluginOff(extension)
- : pluginOn(extension)
- "
- @view-handlers="showPluginInfo(extension)"
- @view-readme="viewReadme(extension)"
- @view-changelog="viewChangelog(extension)"
- >
-
-
-
-
-
-
+
+
+
+
{{ tm("tabs.handlersOperation") }}
+
+
{
+
+
+
{{ tm("tabs.installedMcpServers") }}
+
+
{
+
+
+
{{ tm("tabs.skills") }}
+
+
{
-
-
-
-
-
-
- mdi-source-branch
-
- {{ tm("market.source") }}
-
-
-
-
-
-
- {{
- selectedSource
- ? customSources.find(
- (s) => s.url === selectedSource,
- )?.name
- : tm("market.defaultSource")
- }}
-
- mdi-chevron-down
- {{
- selectedSource || tm("market.defaultOfficialSource")
- }}
-
-
-
-
- {{ tm("market.availableSources") }}
-
-
-
-
-
- {{
- tm("market.defaultSource")
- }}
-
-
-
-
-
-
-
-
- {{ source.name }}
- {{
- source.url
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ tm("market.allPlugins") }}({{
- filteredMarketPlugins.length
- }})
-
-
- mdi-refresh
-
-
-
-
-
-
-
-
-
- mdi-sort
-
-
-
-
-
- {{
- sortOrder === "desc"
- ? "mdi-sort-descending"
- : "mdi-sort-ascending"
- }}
-
- {{
- sortOrder === "desc"
- ? tm("sort.descending")
- : tm("sort.ascending")
- }}
-
-
-
-
-
-
-
-
-
-
-
- 🥳 推荐
-
-
-
-
-
-
-
-
-
-
-
- {{
- plugin.display_name?.length
- ? plugin.display_name
- : showPluginFullName
- ? plugin.name
- : plugin.trimmedName
- }}
-
-
-
-
-
-
-
-
- {{ plugin.desc }}
-
-
-
-
-
-
- {{ plugin.stars }}
-
-
-
- {{
- new Date(plugin.updated_at).toLocaleString()
- }}
-
-
-
-
-
-
-
-
- {{ tag === "danger" ? tm("tags.danger") : tag }}
-
-
-
-
- +{{ plugin.tags.length - 2 }}
-
-
-
-
-
- {{ tag === "danger" ? tm("tags.danger") : tag }}
-
-
-
-
-
-
-
- {{ tm("buttons.viewRepo") }}
-
-
- {{ tm("buttons.install") }}
-
-
- ✓ {{ tm("status.installed") }}
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -2465,6 +509,31 @@ watch(activeTab, (newTab) => {
+
+
+
+
+ mdi-alert
+ {{ tm("dialogs.versionCompatibility.title") }}
+
+
+ {{ tm("dialogs.versionCompatibility.message") }}
+
+ {{ versionCompatibilityDialog.message }}
+
+
+
+
+
+ {{ tm("dialogs.versionCompatibility.cancel") }}
+
+
+ {{ tm("dialogs.versionCompatibility.confirm") }}
+
+
+
+
+
{
placeholder="https://github.com/username/repo"
>
+
+
+ {{ tm("card.status.astrbotVersion") }}:
+ {{ selectedInstallPlugin.astrbot_version }}
+
+
+ {{ tm("card.status.supportPlatform") }}:
+ {{
+ getPlatformDisplayList(selectedInstallPlugin.support_platforms).join(
+ ", ",
+ )
+ }}
+
+
+ {{ installCompat.message }}
+
+
+
@@ -2564,6 +673,95 @@ watch(activeTab, (newTab) => {
+
+
+
+ {{
+ tm("market.sourceManagement")
+ }}
+
+
+
+
+
{{ tm("market.availableSources") }}
+
+ {{ tm("market.addSource") }}
+
+
+
+
+
+
+
+
+ {{ tm("market.defaultSource") }}
+
+
+
+
+
+
+ {{ source.name }}
+ {{
+ source.url
+ }}
+
+
+
+
+
+
+
+
+
+ {{
+ tm("buttons.close")
+ }}
+
+
+
+
@@ -2668,38 +866,6 @@ watch(activeTab, (newTab) => {
background-color: #f5f5f5;
}
-.plugin-description {
- color: rgba(var(--v-theme-on-surface), 0.6);
- line-height: 1.3;
- margin-bottom: 6px;
- flex: 1;
- overflow-y: hidden;
-}
-
-.plugin-card:hover .plugin-description {
- overflow-y: auto;
-}
-
-.plugin-description::-webkit-scrollbar {
- width: 8px;
- height: 8px;
-}
-
-.plugin-description::-webkit-scrollbar-track {
- background: transparent;
-}
-
-.plugin-description::-webkit-scrollbar-thumb {
- background-color: rgba(var(--v-theme-primary-rgb), 0.4);
- border-radius: 4px;
- border: 2px solid transparent;
- background-clip: content-box;
-}
-
-.plugin-description::-webkit-scrollbar-thumb:hover {
- background-color: rgba(var(--v-theme-primary-rgb), 0.6);
-}
-
.fab-button {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
diff --git a/dashboard/src/views/PlatformPage.vue b/dashboard/src/views/PlatformPage.vue
index 2860b6f0b..f50df9554 100644
--- a/dashboard/src/views/PlatformPage.vue
+++ b/dashboard/src/views/PlatformPage.vue
@@ -195,8 +195,12 @@ import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ItemCard from '@/components/shared/ItemCard.vue';
import AddNewPlatform from '@/components/platform/AddNewPlatform.vue';
import { useCommonStore } from '@/stores/common';
-import { useI18n, useModuleI18n } from '@/i18n/composables';
+import { useI18n, useModuleI18n, mergeDynamicTranslations } from '@/i18n/composables';
import { getPlatformIcon, getTutorialLink } from '@/utils/platformUtils';
+import {
+ askForConfirmation as askForConfirmationDialog,
+ useConfirmDialog
+} from '@/utils/confirmDialog';
export default {
name: 'PlatformPage',
@@ -210,10 +214,12 @@ export default {
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/platform');
+ const confirmDialog = useConfirmDialog();
return {
t,
- tm
+ tm,
+ confirmDialog
};
},
data() {
@@ -274,15 +280,25 @@ export default {
this.statsRefreshInterval = setInterval(() => {
this.getPlatformStats();
}, 10000);
+
+ // 监听语言切换事件,重新加载配置以获取插件的 i18n 数据
+ window.addEventListener('astrbot-locale-changed', this.handleLocaleChange);
},
beforeUnmount() {
if (this.statsRefreshInterval) {
clearInterval(this.statsRefreshInterval);
}
+ // 移除语言切换事件监听器
+ window.removeEventListener('astrbot-locale-changed', this.handleLocaleChange);
},
methods: {
+ // 处理语言切换事件,重新加载配置以获取插件的 i18n 数据
+ handleLocaleChange() {
+ this.getConfig();
+ },
+
// 从工具函数导入
getPlatformIcon(platform_id) {
// 首先检查是否有来自插件的 logo_token
@@ -299,6 +315,12 @@ export default {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
+
+ // 将插件平台适配器的 i18n 翻译注入到前端 i18n 系统中
+ const platformI18n = res.data.data.platform_i18n_translations;
+ if (platformI18n && typeof platformI18n === 'object') {
+ mergeDynamicTranslations('features.config-metadata', platformI18n);
+ }
}).catch((err) => {
this.showError(err);
});
@@ -351,8 +373,99 @@ export default {
}
},
+ findPlatformTemplate(platform) {
+ const templates = this.metadata?.platform_group?.metadata?.platform?.config_template || {};
+
+ if (platform?.type && templates[platform.type]) {
+ return templates[platform.type];
+ }
+ if (platform?.id && templates[platform.id]) {
+ return templates[platform.id];
+ }
+
+ for (const template of Object.values(templates)) {
+ if (template?.type === platform?.type) {
+ return template;
+ }
+ }
+ return null;
+ },
+
+ mergeConfigWithTemplate(sourceConfig, templateConfig) {
+ const merge = (source, reference) => {
+ const target = {};
+ const sourceObj = source && typeof source === 'object' && !Array.isArray(source) ? source : {};
+ const referenceObj = reference && typeof reference === 'object' && !Array.isArray(reference) ? reference : null;
+
+ if (!referenceObj) {
+ for (const [key, value] of Object.entries(sourceObj)) {
+ if (Array.isArray(value)) {
+ target[key] = [...value];
+ } else if (value && typeof value === 'object') {
+ target[key] = { ...value };
+ } else {
+ target[key] = value;
+ }
+ }
+ return target;
+ }
+
+ // 1) 先按模板顺序写入,保证字段相对顺序与 template 一致
+ for (const [key, refValue] of Object.entries(referenceObj)) {
+ const hasSourceKey = Object.prototype.hasOwnProperty.call(sourceObj, key);
+ const sourceValue = sourceObj[key];
+
+ if (refValue && typeof refValue === 'object' && !Array.isArray(refValue)) {
+ target[key] = merge(
+ hasSourceKey && sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
+ ? sourceValue
+ : {},
+ refValue
+ );
+ continue;
+ }
+
+ if (hasSourceKey) {
+ if (Array.isArray(sourceValue)) {
+ target[key] = [...sourceValue];
+ } else if (sourceValue && typeof sourceValue === 'object') {
+ target[key] = { ...sourceValue };
+ } else {
+ target[key] = sourceValue;
+ }
+ } else if (Array.isArray(refValue)) {
+ target[key] = [...refValue];
+ } else {
+ target[key] = refValue;
+ }
+ }
+
+ // 2) 再补充 source 中模板没有的额外字段,保持旧配置兼容性
+ for (const [key, value] of Object.entries(sourceObj)) {
+ if (Object.prototype.hasOwnProperty.call(referenceObj, key)) {
+ continue;
+ }
+ if (Array.isArray(value)) {
+ target[key] = [...value];
+ } else if (value && typeof value === 'object') {
+ target[key] = { ...value };
+ } else {
+ target[key] = value;
+ }
+ }
+
+ return target;
+ };
+
+ return merge(sourceConfig, templateConfig);
+ },
+
editPlatform(platform) {
- this.updatingPlatformConfig = JSON.parse(JSON.stringify(platform));
+ const platformCopy = JSON.parse(JSON.stringify(platform));
+ const template = this.findPlatformTemplate(platformCopy);
+ this.updatingPlatformConfig = template
+ ? this.mergeConfigWithTemplate(platformCopy, template)
+ : platformCopy;
this.updatingMode = true;
this.showAddPlatformDialog = true;
this.$nextTick(() => {
@@ -360,15 +473,18 @@ export default {
});
},
- deletePlatform(platform) {
- if (confirm(`${this.messages.deleteConfirm} ${platform.id}?`)) {
- axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
- this.getConfig();
- this.showSuccess(res.data.message || this.messages.deleteSuccess);
- }).catch((err) => {
- this.showError(err.response?.data?.message || err.message);
- });
+ async deletePlatform(platform) {
+ const message = `${this.messages.deleteConfirm} ${platform.id}?`;
+ if (!(await askForConfirmationDialog(message, this.confirmDialog))) {
+ return;
}
+
+ axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
+ this.getConfig();
+ this.showSuccess(res.data.message || this.messages.deleteSuccess);
+ }).catch((err) => {
+ this.showError(err.response?.data?.message || err.message);
+ });
},
platformStatusChange(platform) {
diff --git a/dashboard/src/views/SessionManagementPage.vue b/dashboard/src/views/SessionManagementPage.vue
index b754f8c1c..5008e1dd3 100644
--- a/dashboard/src/views/SessionManagementPage.vue
+++ b/dashboard/src/views/SessionManagementPage.vue
@@ -522,16 +522,22 @@
diff --git a/dashboard/src/views/SubAgentPage.vue b/dashboard/src/views/SubAgentPage.vue
index 892b628b6..029cc5a82 100644
--- a/dashboard/src/views/SubAgentPage.vue
+++ b/dashboard/src/views/SubAgentPage.vue
@@ -1,166 +1,248 @@
-
+
-
+
{{ tm('page.title') }}
- {{ tm('page.beta') }}
+
+ {{ tm('page.beta') }}
+
{{ tm('page.subtitle') }}
-
-
{{ tm('actions.refresh') }}
-
{{ tm('actions.save') }}
+
+
+ {{ tm('actions.refresh') }}
+
+
+ {{ tm('actions.save') }}
+
-
+
+
-
+
+
+
{{ tm('section.globalSettings') || 'Global Settings' }}
+
+ {{ mainStateDescription }}
+
+
+
+
+
+
+
+ >
+
+
+ {{ tm('switches.enable') }}
+ {{ tm('switches.enableHint') }}
+
+
+
+ >
+
+
+ {{ tm('switches.dedupe') }}
+ {{ tm('switches.dedupeHint') }}
+
+
+
-
-
- {{ mainStateDescription }}
-
-
-
-
{{ tm('section.title') }}
-
- {{ tm('actions.add') }}
-
-
-
-
-
-
-
-
-
- {{ agent.enabled ? tm('cards.statusEnabled') : tm('cards.statusDisabled') }}
-
-
-
-
{{ agent.name || tm('cards.unnamed') }}
-
- {{ tm('cards.transferPrefix', { name: agent.name || '...' }) }}
-
-
-
-
-
-
- {{ tm('cards.switchLabel') }}
-
-
-
- {{ tm('actions.delete') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ tm('cards.previewTitle') }}
-
-
- {{ tm('cards.transferPrefix', { name: agent.name || '...' }) }}
-
-
- {{ tm('cards.personaChip', { id: agent.persona_id }) }}
-
-
-
-
-
-
-
+
+
+
+
+
{{ tm('section.title') }}
+
+ {{ cfg.agents.length }}
+
+
+
+ {{ tm('actions.add') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ agent.name || tm('cards.unnamed') }}
+
+
+
+ {{ agent.public_description || tm('cards.noDescription') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ tm('form.providerLabel') }}
+
+
+
+
+
+
+
{{ tm('form.personaLabel') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm('cards.personaPreview') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ tm('empty.title') }}
+
{{ tm('empty.subtitle') }}
+
+ {{ tm('empty.action') }}
+
+
+
+
{{ snackbar.message }}
+
+ {{ tm('actions.close') }}
+
@@ -169,9 +251,12 @@
import { computed, onMounted, ref } from 'vue'
import axios from 'axios'
import ProviderSelector from '@/components/shared/ProviderSelector.vue'
+import PersonaSelector from '@/components/shared/PersonaSelector.vue'
+import PersonaQuickPreview from '@/components/shared/PersonaQuickPreview.vue'
import { useModuleI18n } from '@/i18n/composables'
type SubAgentItem = {
+
__key: string
name: string
persona_id: string
@@ -207,9 +292,6 @@ const cfg = ref
({
agents: []
})
-const personaOptions = ref<{ title: string; value: string }[]>([])
-const personaLoading = ref(false)
-
const mainStateDescription = computed(() =>
cfg.value.main_enable ? tm('description.enabled') : tm('description.disabled')
)
@@ -255,24 +337,6 @@ async function loadConfig() {
}
}
-async function loadPersonas() {
- personaLoading.value = true
- try {
- const res = await axios.get('/api/persona/list')
- if (res.data.status === 'ok') {
- const list = Array.isArray(res.data.data) ? res.data.data : []
- personaOptions.value = list.map((p: any) => ({
- title: p.persona_id,
- value: p.persona_id
- }))
- }
- } catch (e: any) {
- toast(e?.response?.data?.message || tm('messages.loadPersonaFailed'), 'error')
- } finally {
- personaLoading.value = false
- }
-}
-
function addAgent() {
cfg.value.agents.push({
__key: `${Date.now()}_${Math.random().toString(16).slice(2)}`,
@@ -344,7 +408,7 @@ async function save() {
}
async function reload() {
- await Promise.all([loadConfig(), loadPersonas()])
+ await Promise.all([loadConfig()])
}
onMounted(() => {
@@ -354,101 +418,21 @@ onMounted(() => {
-
-
diff --git a/dashboard/src/views/WelcomePage.vue b/dashboard/src/views/WelcomePage.vue
new file mode 100644
index 000000000..5cabdc66a
--- /dev/null
+++ b/dashboard/src/views/WelcomePage.vue
@@ -0,0 +1,433 @@
+
+
+
+
+
+
+ {{ greetingText }} {{ greetingEmoji }}
+
+
+ {{ tm('subtitle') }}
+
+
+
+
+
+
+
+
+ {{ tm('onboard.title') }}
+
+
+
+
+
+
{{ tm('onboard.step1Title') }}
+
{{ tm('onboard.step1Desc') }}
+
+
+ {{ tm('onboard.configure') }}
+
+
+ {{ tm('onboard.completed') }}
+
+
+
+
+
+
+
+
{{ tm('onboard.step2Title')
+ }}
+
+
{{ tm('onboard.step2Desc') }}
+
+
+ {{ tm('onboard.configure') }}
+
+
+ {{ tm('onboard.completed') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm('resources.title') }}
+
+
+
+
+
+
+ mdi-github
+ GitHub
+
+
+ {{ tm('resources.githubDesc') }}
+
+
+
+
+
+
+
+
+ mdi-book-open-variant
+ {{ tm('resources.docsTitle') }}
+
+
+ {{ tm('resources.docsDesc') }}
+
+
+
+
+
+
+
+
+ mdi-hand-heart
+ {{ tm('resources.afdianTitle') }}
+
+
+ {{ tm('resources.afdianDesc') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm('announcement.title') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/views/authentication/auth/LoginPage.vue b/dashboard/src/views/authentication/auth/LoginPage.vue
index b659eae27..c647dc8e5 100644
--- a/dashboard/src/views/authentication/auth/LoginPage.vue
+++ b/dashboard/src/views/authentication/auth/LoginPage.vue
@@ -1,56 +1,23 @@
+
+
+
+
+
+
{{ tm("titles.installedAstrBotPlugins") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ showReserved ? "mdi-eye-off" : "mdi-eye"
+ }}
+ {{
+ showReserved
+ ? tm("buttons.hideSystemPlugins")
+ : tm("buttons.showSystemPlugins")
+ }}
+
+
+
+ mdi-update
+ {{ tm("buttons.updateAll") }}
+
+
+
+
+
+ mdi-alert-circle
+
+
+
+
+
+ mdi-alert-circle
+ {{ tm("dialogs.error.title") }}
+
+
+
+ {{ extension_data.message }}
+
+
+ {{ tm("dialogs.error.checkConsole") }}
+
+
+
+
+ 尝试一键重载修复
+
+
+ {{ tm("buttons.close") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm("status.loading") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ item.display_name && item.display_name.length
+ ? item.display_name
+ : item.name
+ }}
+
+
+ {{ item.name }}
+
+
+ {{ tm("status.system") }}
+
+
+
+
+
+
+
+
+ {{ item.desc }}
+
+
+
+ {{ tm("card.status.supportPlatform") }}:
+
+
+ {{ platformId }}
+
+
+
+
+ {{ tm("card.status.astrbotVersion") }}:
+
+
+ {{ item.astrbot_version }}
+
+
+
+
+
+
+
+ {{ item.version }}
+ mdi-alert
+
+ {{ tm("messages.hasUpdate") }}
+ {{ item.online_version }}
+
+
+
+
+
+ {{ item.author }}
+
+
+
+
+
+ {{ tm("buttons.enable") }}
+
+
+ {{ tm("buttons.disable") }}
+
+
+
+ {{ tm("buttons.reload") }}
+
+
+
+ {{ tm("buttons.configure") }}
+
+
+
+ {{ tm("buttons.viewDocs") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
mdi-puzzle-outline
+
+ {{ tm("empty.noPlugins") }}
+
+
+ {{ tm("empty.noPluginsDesc") }}
+
+
+
+
+
+
+
+
+
+
+
+ mdi-puzzle-outline
+ {{ tm("empty.noPlugins") }}
+
+ {{ tm("empty.noPluginsDesc") }}
+
+
+
+
+
+
+ uninstallExtension(ext.name, options)
+ "
+ @update="updateExtension(extension.name)"
+ @reload="reloadPlugin(extension.name)"
+ @toggle-activation="
+ extension.activated
+ ? pluginOff(extension)
+ : pluginOn(extension)
+ "
+ @view-handlers="showPluginInfo(extension)"
+ @view-readme="viewReadme(extension)"
+ @view-changelog="viewChangelog(extension)"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/views/extension/MarketPluginsTab.vue b/dashboard/src/views/extension/MarketPluginsTab.vue
new file mode 100644
index 000000000..63cc6b957
--- /dev/null
+++ b/dashboard/src/views/extension/MarketPluginsTab.vue
@@ -0,0 +1,373 @@
+
+
+
+
+
+
+
{{ tm("tabs.market") }}
+
+
+
+
+ mdi-source-branch
+
+ {{ currentSourceName }}
+
+
+
+
+
+
+
+
+
+
+ mdi-alert-outline
+ {{ tm("market.sourceSafetyWarning") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm("market.randomPlugins") }}
+
+
+ {{ tm("buttons.reshuffle") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ tm("market.allPlugins") }}({{
+ filteredMarketPlugins.length
+ }})
+
+
+ mdi-refresh
+
+
+
+
+
+
+ mdi-sort
+
+
+
+
+ {{
+ sortOrder === "desc"
+ ? "mdi-sort-descending"
+ : "mdi-sort-ascending"
+ }}
+
+ {{
+ sortOrder === "desc"
+ ? tm("sort.descending")
+ : tm("sort.ascending")
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/views/extension/useExtensionPage.js b/dashboard/src/views/extension/useExtensionPage.js
new file mode 100644
index 000000000..93b716e2a
--- /dev/null
+++ b/dashboard/src/views/extension/useExtensionPage.js
@@ -0,0 +1,1466 @@
+import axios from "axios";
+import { pinyin } from "pinyin-pro";
+import { useCommonStore } from "@/stores/common";
+import { useI18n, useModuleI18n } from "@/i18n/composables";
+import defaultPluginIcon from "@/assets/images/plugin_icon.png";
+import { getPlatformDisplayName } from "@/utils/platformUtils";
+import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
+import { useRoute, useRouter } from "vue-router";
+
+export const useExtensionPage = () => {
+
+
+ const commonStore = useCommonStore();
+ const { t } = useI18n();
+ const { tm } = useModuleI18n("features/extension");
+ const router = useRouter();
+ const route = useRoute();
+
+ const getSelectedGitHubProxy = () => {
+ if (typeof window === "undefined" || !window.localStorage) return "";
+ return localStorage.getItem("githubProxyRadioValue") === "1"
+ ? localStorage.getItem("selectedGitHubProxy") || ""
+ : "";
+ };
+
+ // 检查指令冲突并提示
+ const conflictDialog = reactive({
+ show: false,
+ count: 0,
+ });
+ const checkAndPromptConflicts = async () => {
+ try {
+ const res = await axios.get("/api/commands");
+ if (res.data.status === "ok") {
+ const conflicts = res.data.data.summary?.conflicts || 0;
+ if (conflicts > 0) {
+ conflictDialog.count = conflicts;
+ conflictDialog.show = true;
+ }
+ }
+ } catch (err) {
+ console.debug("Failed to check command conflicts:", err);
+ }
+ };
+ const handleConflictConfirm = () => {
+ activeTab.value = "commands";
+ };
+
+ const fileInput = ref(null);
+ const activeTab = ref("installed");
+ const validTabs = ["installed", "market", "mcp", "skills", "components"];
+ const isValidTab = (tab) => validTabs.includes(tab);
+ const getLocationHash = () =>
+ typeof window !== "undefined" ? window.location.hash : "";
+ const extractTabFromHash = (hash) => {
+ const lastHashIndex = (hash || "").lastIndexOf("#");
+ if (lastHashIndex === -1) return "";
+ return hash.slice(lastHashIndex + 1);
+ };
+ const syncTabFromHash = (hash) => {
+ const tab = extractTabFromHash(hash);
+ if (isValidTab(tab)) {
+ activeTab.value = tab;
+ return true;
+ }
+ return false;
+ };
+ const extension_data = reactive({
+ data: [],
+ message: "",
+ });
+
+ // 从 localStorage 恢复显示系统插件的状态,默认为 false(隐藏)
+ const getInitialShowReserved = () => {
+ if (typeof window !== "undefined" && window.localStorage) {
+ const saved = localStorage.getItem("showReservedPlugins");
+ return saved === "true";
+ }
+ return false;
+ };
+ const showReserved = ref(getInitialShowReserved());
+ const snack_message = ref("");
+ const snack_show = ref(false);
+ const snack_success = ref("success");
+ const configDialog = ref(false);
+ const extension_config = reactive({
+ metadata: {},
+ config: {},
+ });
+ const pluginMarketData = ref([]);
+ const loadingDialog = reactive({
+ show: false,
+ title: "",
+ statusCode: 0, // 0: loading, 1: success, 2: error,
+ result: "",
+ });
+ const showPluginInfoDialog = ref(false);
+ const selectedPlugin = ref({});
+ const curr_namespace = ref("");
+ const updatingAll = ref(false);
+
+ const readmeDialog = reactive({
+ show: false,
+ pluginName: "",
+ repoUrl: null,
+ });
+
+ // 强制更新确认对话框
+ const forceUpdateDialog = reactive({
+ show: false,
+ extensionName: "",
+ });
+
+ // 更新全部插件确认对话框
+ const updateAllConfirmDialog = reactive({
+ show: false,
+ });
+
+ // 插件更新日志对话框(复用 ReadmeDialog)
+ const changelogDialog = reactive({
+ show: false,
+ pluginName: "",
+ repoUrl: null,
+ });
+
+ // 新增变量支持列表视图
+ // 从 localStorage 恢复显示模式,默认为 false(卡片视图)
+ const getInitialListViewMode = () => {
+ if (typeof window !== "undefined" && window.localStorage) {
+ return localStorage.getItem("pluginListViewMode") === "true";
+ }
+ return false;
+ };
+ const isListView = ref(getInitialListViewMode());
+ const pluginSearch = ref("");
+ const loading_ = ref(false);
+
+ // 分页相关
+ const currentPage = ref(1);
+
+ // 危险插件确认对话框
+ const dangerConfirmDialog = ref(false);
+ const selectedDangerPlugin = ref(null);
+ const selectedMarketInstallPlugin = ref(null);
+ const installCompat = reactive({
+ checked: false,
+ compatible: true,
+ message: "",
+ });
+
+ // AstrBot 版本范围不兼容警告对话框
+ const versionCompatibilityDialog = reactive({
+ show: false,
+ message: "",
+ });
+
+ // 卸载插件确认对话框(列表模式用)
+ const showUninstallDialog = ref(false);
+ const pluginToUninstall = ref(null);
+
+ // 自定义插件源相关
+ const showSourceDialog = ref(false);
+ const showSourceManagerDialog = ref(false);
+ const sourceName = ref("");
+ const sourceUrl = ref("");
+ const customSources = ref([]);
+ const selectedSource = ref(null);
+ const showRemoveSourceDialog = ref(false);
+ const sourceToRemove = ref(null);
+ const editingSource = ref(false);
+ const originalSourceUrl = ref("");
+
+ // 插件市场相关
+ const extension_url = ref("");
+ const dialog = ref(false);
+ const upload_file = ref(null);
+ const uploadTab = ref("file");
+ const showPluginFullName = ref(false);
+ const marketSearch = ref("");
+ const debouncedMarketSearch = ref("");
+ const refreshingMarket = ref(false);
+ const sortBy = ref("default"); // default, stars, author, updated
+ const sortOrder = ref("desc"); // desc (降序) or asc (升序)
+ const randomPluginNames = ref([]);
+
+ // 插件市场拼音搜索
+ const normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim();
+ const toPinyinText = (s) =>
+ pinyin(s ?? "", { toneType: "none" })
+ .toLowerCase()
+ .replace(/\s+/g, "");
+ const toInitials = (s) =>
+ pinyin(s ?? "", { pattern: "first", toneType: "none" })
+ .toLowerCase()
+ .replace(/\s+/g, "");
+ const marketCustomFilter = (value, query, item) => {
+ const q = normalizeStr(query);
+ if (!q) return true;
+
+ const candidates = new Set();
+ if (value != null) candidates.add(String(value));
+ if (item?.name) candidates.add(String(item.name));
+ if (item?.trimmedName) candidates.add(String(item.trimmedName));
+ if (item?.display_name) candidates.add(String(item.display_name));
+ if (item?.desc) candidates.add(String(item.desc));
+ if (item?.author) candidates.add(String(item.author));
+
+ for (const v of candidates) {
+ const nv = normalizeStr(v);
+ if (nv.includes(q)) return true;
+ const pv = toPinyinText(v);
+ if (pv.includes(q)) return true;
+ const iv = toInitials(v);
+ if (iv.includes(q)) return true;
+ }
+ return false;
+ };
+
+ const plugin_handler_info_headers = computed(() => [
+ { title: tm("table.headers.eventType"), key: "event_type_h" },
+ { title: tm("table.headers.description"), key: "desc", maxWidth: "250px" },
+ { title: tm("table.headers.specificType"), key: "type" },
+ { title: tm("table.headers.trigger"), key: "cmd" },
+ ]);
+
+ // 插件表格的表头定义
+ const pluginHeaders = computed(() => [
+ { title: tm("table.headers.name"), key: "name", width: "200px" },
+ { title: tm("table.headers.description"), key: "desc", width: "180px" },
+ { title: tm("table.headers.version"), key: "version", width: "100px" },
+ { title: tm("table.headers.author"), key: "author", width: "100px" },
+ {
+ title: tm("table.headers.actions"),
+ key: "actions",
+ sortable: false,
+ width: "520px",
+ },
+ ]);
+
+ // 过滤要显示的插件
+ const filteredExtensions = computed(() => {
+ const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
+ if (!showReserved.value) {
+ return data.filter((ext) => !ext.reserved);
+ }
+ return data;
+ });
+
+ // 通过搜索过滤插件
+ const filteredPlugins = computed(() => {
+ if (!pluginSearch.value) {
+ return filteredExtensions.value;
+ }
+
+ const search = pluginSearch.value.toLowerCase();
+ return filteredExtensions.value.filter((plugin) => {
+ const supportPlatforms = Array.isArray(plugin.support_platforms)
+ ? plugin.support_platforms.join(" ").toLowerCase()
+ : "";
+ const astrbotVersion = (plugin.astrbot_version ?? "").toLowerCase();
+ return (
+ plugin.name?.toLowerCase().includes(search) ||
+ plugin.desc?.toLowerCase().includes(search) ||
+ plugin.author?.toLowerCase().includes(search) ||
+ supportPlatforms.includes(search) ||
+ astrbotVersion.includes(search)
+ );
+ });
+ });
+
+ // 过滤后的插件市场数据(带搜索)
+ const filteredMarketPlugins = computed(() => {
+ if (!debouncedMarketSearch.value) {
+ return pluginMarketData.value;
+ }
+
+ const search = debouncedMarketSearch.value.toLowerCase();
+ return pluginMarketData.value.filter((plugin) => {
+ // 使用自定义过滤器
+ return (
+ marketCustomFilter(plugin.name, search, plugin) ||
+ marketCustomFilter(plugin.desc, search, plugin) ||
+ marketCustomFilter(plugin.author, search, plugin)
+ );
+ });
+ });
+
+ // 所有插件列表,推荐插件排在前面
+ const sortedPlugins = computed(() => {
+ let plugins = [...filteredMarketPlugins.value];
+
+ // 根据排序选项排序
+ if (sortBy.value === "stars") {
+ // 按 star 数排序
+ plugins.sort((a, b) => {
+ const starsA = a.stars ?? 0;
+ const starsB = b.stars ?? 0;
+ return sortOrder.value === "desc" ? starsB - starsA : starsA - starsB;
+ });
+ } else if (sortBy.value === "author") {
+ // 按作者名字典序排序
+ plugins.sort((a, b) => {
+ const authorA = (a.author ?? "").toLowerCase();
+ const authorB = (b.author ?? "").toLowerCase();
+ const result = authorA.localeCompare(authorB);
+ return sortOrder.value === "desc" ? -result : result;
+ });
+ } else if (sortBy.value === "updated") {
+ // 按更新时间排序
+ plugins.sort((a, b) => {
+ const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0;
+ const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0;
+ return sortOrder.value === "desc" ? dateB - dateA : dateA - dateB;
+ });
+ } else {
+ // default: 推荐插件排在前面
+ const pinned = plugins.filter((plugin) => plugin?.pinned);
+ const notPinned = plugins.filter((plugin) => !plugin?.pinned);
+ return [...pinned, ...notPinned];
+ }
+
+ return plugins;
+ });
+
+ const RANDOM_PLUGINS_COUNT = 3;
+
+ const randomPlugins = computed(() => {
+ const allPlugins = pluginMarketData.value;
+ if (allPlugins.length === 0) return [];
+
+ const pluginsByName = new Map(allPlugins.map((plugin) => [plugin.name, plugin]));
+ const selected = randomPluginNames.value
+ .map((name) => pluginsByName.get(name))
+ .filter(Boolean);
+
+ if (selected.length > 0) {
+ return selected;
+ }
+
+ return allPlugins.slice(0, Math.min(RANDOM_PLUGINS_COUNT, allPlugins.length));
+ });
+
+ const shufflePlugins = (plugins) => {
+ const shuffled = [...plugins];
+ for (let i = shuffled.length - 1; i > 0; i -= 1) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
+ }
+ return shuffled;
+ };
+
+ const refreshRandomPlugins = () => {
+ const shuffled = shufflePlugins(pluginMarketData.value);
+ randomPluginNames.value = shuffled
+ .slice(0, Math.min(RANDOM_PLUGINS_COUNT, shuffled.length))
+ .map((plugin) => plugin.name);
+ };
+
+ // 分页计算属性
+ const displayItemsPerPage = 9; // 固定每页显示9个卡片(3行)
+
+ const totalPages = computed(() => {
+ return Math.ceil(sortedPlugins.value.length / displayItemsPerPage);
+ });
+
+ const paginatedPlugins = computed(() => {
+ const start = (currentPage.value - 1) * displayItemsPerPage;
+ const end = start + displayItemsPerPage;
+ return sortedPlugins.value.slice(start, end);
+ });
+
+ const updatableExtensions = computed(() => {
+ const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
+ return data.filter((ext) => ext.has_update);
+ });
+
+ // 方法
+ const toggleShowReserved = () => {
+ showReserved.value = !showReserved.value;
+ // 保存到 localStorage
+ if (typeof window !== "undefined" && window.localStorage) {
+ localStorage.setItem("showReservedPlugins", showReserved.value.toString());
+ }
+ };
+
+ const toast = (message, success) => {
+ snack_message.value = message;
+ snack_show.value = true;
+ snack_success.value = success;
+ };
+
+ const resetLoadingDialog = () => {
+ loadingDialog.show = false;
+ loadingDialog.title = tm("dialogs.loading.title");
+ loadingDialog.statusCode = 0;
+ loadingDialog.result = "";
+ };
+
+ const onLoadingDialogResult = (statusCode, result, timeToClose = 2000) => {
+ loadingDialog.statusCode = statusCode;
+ loadingDialog.result = result;
+ if (timeToClose === -1) return;
+ setTimeout(resetLoadingDialog, timeToClose);
+ };
+
+ const failedPluginsDict = ref({});
+
+ const getExtensions = async () => {
+ loading_.value = true;
+ try {
+ const res = await axios.get("/api/plugin/get");
+ Object.assign(extension_data, res.data);
+
+ const failRes = await axios.get("/api/plugin/source/get-failed-plugins");
+ failedPluginsDict.value = failRes.data.data || {};
+
+ checkUpdate();
+ } catch (err) {
+ toast(err, "error");
+ } finally {
+ loading_.value = false;
+ }
+ };
+
+ const handleReloadAllFailed = async () => {
+ const dirNames = Object.keys(failedPluginsDict.value);
+ if (dirNames.length === 0) {
+ toast("没有需要重载的失败插件", "info");
+ return;
+ }
+
+ loading_.value = true;
+ try {
+ const promises = dirNames.map(dir =>
+ axios.post("/api/plugin/reload-failed", { dir_name: dir })
+ );
+ await Promise.all(promises);
+
+ toast("已尝试重载所有失败插件", "success");
+
+ // 清空 message 关闭对话框
+ extension_data.message = "";
+
+ // 刷新列表
+ await getExtensions();
+
+ } catch (e) {
+ console.error("重载失败:", e);
+ toast("批量重载过程中出现错误", "error");
+ } finally {
+ loading_.value = false;
+ }
+ };
+
+ const checkUpdate = () => {
+ const onlinePluginsMap = new Map();
+ const onlinePluginsNameMap = new Map();
+
+ pluginMarketData.value.forEach((plugin) => {
+ if (plugin.repo) {
+ onlinePluginsMap.set(plugin.repo.toLowerCase(), plugin);
+ }
+ onlinePluginsNameMap.set(plugin.name, plugin);
+ });
+
+ const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
+ data.forEach((extension) => {
+ const repoKey = extension.repo?.toLowerCase();
+ const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null;
+ const onlinePluginByName = onlinePluginsNameMap.get(extension.name);
+ const matchedPlugin = onlinePlugin || onlinePluginByName;
+
+ if (matchedPlugin) {
+ extension.online_version = matchedPlugin.version;
+ extension.has_update =
+ extension.version !== matchedPlugin.version &&
+ matchedPlugin.version !== tm("status.unknown");
+ } else {
+ extension.has_update = false;
+ }
+ });
+ };
+
+ const uninstallExtension = async (
+ extension_name,
+ optionsOrSkipConfirm = false,
+ ) => {
+ let deleteConfig = false;
+ let deleteData = false;
+ let skipConfirm = false;
+
+ // 处理参数:可能是布尔值(旧的 skipConfirm)或对象(新的选项)
+ if (typeof optionsOrSkipConfirm === "boolean") {
+ skipConfirm = optionsOrSkipConfirm;
+ } else if (
+ typeof optionsOrSkipConfirm === "object" &&
+ optionsOrSkipConfirm !== null
+ ) {
+ deleteConfig = optionsOrSkipConfirm.deleteConfig || false;
+ deleteData = optionsOrSkipConfirm.deleteData || false;
+ skipConfirm = true; // 如果传递了选项对象,说明已经确认过了
+ }
+
+ // 如果没有跳过确认且没有传递选项对象,显示自定义卸载对话框
+ if (!skipConfirm) {
+ pluginToUninstall.value = extension_name;
+ showUninstallDialog.value = true;
+ return; // 等待对话框回调
+ }
+
+ // 执行卸载
+ toast(tm("messages.uninstalling") + " " + extension_name, "primary");
+ try {
+ const res = await axios.post("/api/plugin/uninstall", {
+ name: extension_name,
+ delete_config: deleteConfig,
+ delete_data: deleteData,
+ });
+ if (res.data.status === "error") {
+ toast(res.data.message, "error");
+ return;
+ }
+ Object.assign(extension_data, res.data);
+ toast(res.data.message, "success");
+ getExtensions();
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ // 处理卸载确认对话框的确认事件
+ const handleUninstallConfirm = (options) => {
+ if (pluginToUninstall.value) {
+ uninstallExtension(pluginToUninstall.value, options);
+ pluginToUninstall.value = null;
+ }
+ };
+
+ const updateExtension = async (extension_name, forceUpdate = false) => {
+ // 查找插件信息
+ const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
+ const ext = data.find((e) => e.name === extension_name);
+
+ // 如果没有检测到更新且不是强制更新,则弹窗确认
+ if (!ext?.has_update && !forceUpdate) {
+ forceUpdateDialog.extensionName = extension_name;
+ forceUpdateDialog.show = true;
+ return;
+ }
+
+ loadingDialog.title = tm("status.loading");
+ loadingDialog.show = true;
+ try {
+ const res = await axios.post("/api/plugin/update", {
+ name: extension_name,
+ proxy: getSelectedGitHubProxy(),
+ });
+
+ if (res.data.status === "error") {
+ onLoadingDialogResult(2, res.data.message, -1);
+ return;
+ }
+
+ Object.assign(extension_data, res.data);
+ onLoadingDialogResult(1, res.data.message);
+ setTimeout(async () => {
+ toast(tm("messages.refreshing"), "info", 2000);
+ try {
+ await getExtensions();
+ toast(tm("messages.refreshSuccess"), "success");
+
+ // 更新完成后弹出更新日志
+ viewChangelog({
+ name: extension_name,
+ repo: ext?.repo || null,
+ });
+ } catch (error) {
+ const errorMsg =
+ error.response?.data?.message || error.message || String(error);
+ toast(`${tm("messages.refreshFailed")}: ${errorMsg}`, "error");
+ }
+ }, 1000);
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ // 确认强制更新
+ // 显示更新全部插件确认对话框
+ const showUpdateAllConfirm = () => {
+ if (updatableExtensions.value.length === 0) return;
+ updateAllConfirmDialog.show = true;
+ };
+
+ // 确认更新全部插件
+ const confirmUpdateAll = () => {
+ updateAllConfirmDialog.show = false;
+ updateAllExtensions();
+ };
+
+ // 取消更新全部插件
+ const cancelUpdateAll = () => {
+ updateAllConfirmDialog.show = false;
+ };
+
+ const confirmForceUpdate = () => {
+ const name = forceUpdateDialog.extensionName;
+ forceUpdateDialog.show = false;
+ forceUpdateDialog.extensionName = "";
+ updateExtension(name, true);
+ };
+
+ const updateAllExtensions = async () => {
+ if (updatingAll.value || updatableExtensions.value.length === 0) return;
+ updatingAll.value = true;
+ loadingDialog.title = tm("status.loading");
+ loadingDialog.statusCode = 0;
+ loadingDialog.result = "";
+ loadingDialog.show = true;
+
+ const targets = updatableExtensions.value.map((ext) => ext.name);
+ try {
+ const res = await axios.post("/api/plugin/update-all", {
+ names: targets,
+ proxy: getSelectedGitHubProxy(),
+ });
+
+ if (res.data.status === "error") {
+ onLoadingDialogResult(
+ 2,
+ res.data.message ||
+ tm("messages.updateAllFailed", {
+ failed: targets.length,
+ total: targets.length,
+ }),
+ -1,
+ );
+ return;
+ }
+
+ const results = res.data.data?.results || [];
+ const failures = results.filter((r) => r.status !== "ok");
+ try {
+ await getExtensions();
+ } catch (err) {
+ const errorMsg =
+ err.response?.data?.message || err.message || String(err);
+ failures.push({ name: "refresh", status: "error", message: errorMsg });
+ }
+
+ if (failures.length === 0) {
+ onLoadingDialogResult(1, tm("messages.updateAllSuccess"));
+ } else {
+ const failureText = tm("messages.updateAllFailed", {
+ failed: failures.length,
+ total: targets.length,
+ });
+ const detail = failures.map((f) => `${f.name}: ${f.message}`).join("\n");
+ onLoadingDialogResult(2, `${failureText}\n${detail}`, -1);
+ }
+ } catch (err) {
+ const errorMsg = err.response?.data?.message || err.message || String(err);
+ onLoadingDialogResult(2, errorMsg, -1);
+ } finally {
+ updatingAll.value = false;
+ }
+ };
+
+ const pluginOn = async (extension) => {
+ try {
+ const res = await axios.post("/api/plugin/on", { name: extension.name });
+ if (res.data.status === "error") {
+ toast(res.data.message, "error");
+ return;
+ }
+ toast(res.data.message, "success");
+ await getExtensions();
+
+ await checkAndPromptConflicts();
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ const pluginOff = async (extension) => {
+ try {
+ const res = await axios.post("/api/plugin/off", { name: extension.name });
+ if (res.data.status === "error") {
+ toast(res.data.message, "error");
+ return;
+ }
+ toast(res.data.message, "success");
+ getExtensions();
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ const openExtensionConfig = async (extension_name) => {
+ curr_namespace.value = extension_name;
+ configDialog.value = true;
+ try {
+ const res = await axios.get(
+ "/api/config/get?plugin_name=" + extension_name,
+ );
+ extension_config.metadata = res.data.data.metadata;
+ extension_config.config = res.data.data.config;
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ const updateConfig = async () => {
+ try {
+ const res = await axios.post(
+ "/api/config/plugin/update?plugin_name=" + curr_namespace.value,
+ extension_config.config,
+ );
+ if (res.data.status === "ok") {
+ toast(res.data.message, "success");
+ } else {
+ toast(res.data.message, "error");
+ }
+ configDialog.value = false;
+ extension_config.metadata = {};
+ extension_config.config = {};
+ getExtensions();
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ const showPluginInfo = (plugin) => {
+ selectedPlugin.value = plugin;
+ showPluginInfoDialog.value = true;
+ };
+
+ const reloadPlugin = async (plugin_name) => {
+ try {
+ const res = await axios.post("/api/plugin/reload", { name: plugin_name });
+ await getExtensions();
+ if (res.data.status === "error") {
+ toast(res.data.message, "error");
+ return;
+ }
+ toast(tm("messages.reloadSuccess"), "success");
+ //getExtensions();
+ } catch (err) {
+ toast(err, "error");
+ }
+ };
+
+ const viewReadme = (plugin) => {
+ readmeDialog.pluginName = plugin.name;
+ readmeDialog.repoUrl = plugin.repo;
+ readmeDialog.show = true;
+ };
+
+ // 查看更新日志
+ const viewChangelog = (plugin) => {
+ changelogDialog.pluginName = plugin.name;
+ changelogDialog.repoUrl = plugin.repo;
+ changelogDialog.show = true;
+ };
+
+ // 为表格视图创建一个处理安装插件的函数
+ const handleInstallPlugin = async (plugin) => {
+ if (plugin.tags && plugin.tags.includes("danger")) {
+ selectedDangerPlugin.value = plugin;
+ dangerConfirmDialog.value = true;
+ } else {
+ selectedMarketInstallPlugin.value = plugin;
+ extension_url.value = plugin.repo;
+ dialog.value = true;
+ uploadTab.value = "url";
+ }
+ };
+
+ // 确认安装危险插件
+ const confirmDangerInstall = () => {
+ if (selectedDangerPlugin.value) {
+ selectedMarketInstallPlugin.value = selectedDangerPlugin.value;
+ extension_url.value = selectedDangerPlugin.value.repo;
+ dialog.value = true;
+ uploadTab.value = "url";
+ }
+ dangerConfirmDialog.value = false;
+ selectedDangerPlugin.value = null;
+ };
+
+ // 取消安装危险插件
+ const cancelDangerInstall = () => {
+ dangerConfirmDialog.value = false;
+ selectedDangerPlugin.value = null;
+ };
+
+ // 自定义插件源管理方法
+ const loadCustomSources = async () => {
+ try {
+ const res = await axios.get("/api/plugin/source/get");
+ if (res.data.status === "ok") {
+ customSources.value = res.data.data;
+ } else {
+ toast(res.data.message, "error");
+ }
+ } catch (e) {
+ console.warn("Failed to load custom sources:", e);
+ customSources.value = [];
+ }
+
+ // 加载当前选中的插件源
+ const currentSource = localStorage.getItem("selectedPluginSource");
+ if (currentSource) {
+ selectedSource.value = currentSource;
+ }
+ };
+
+ const saveCustomSources = async () => {
+ try {
+ const res = await axios.post("/api/plugin/source/save", {
+ sources: customSources.value,
+ });
+ if (res.data.status !== "ok") {
+ toast(res.data.message, "error");
+ }
+ } catch (e) {
+ toast(e, "error");
+ }
+ };
+
+ const addCustomSource = () => {
+ showSourceManagerDialog.value = false;
+ editingSource.value = false;
+ originalSourceUrl.value = "";
+ sourceName.value = "";
+ sourceUrl.value = "";
+ showSourceDialog.value = true;
+ };
+
+ const openSourceManagerDialog = async () => {
+ await loadCustomSources();
+ showSourceManagerDialog.value = true;
+ };
+
+ const selectPluginSource = (sourceUrl) => {
+ selectedSource.value = sourceUrl;
+ if (sourceUrl) {
+ localStorage.setItem("selectedPluginSource", sourceUrl);
+ } else {
+ localStorage.removeItem("selectedPluginSource");
+ }
+ // 重新加载插件市场数据
+ refreshPluginMarket();
+ };
+
+ const sourceSelectItems = computed(() => [
+ { title: tm("market.defaultSource"), value: "__default__" },
+ ...customSources.value.map((source) => ({
+ title: source.name,
+ value: source.url,
+ })),
+ ]);
+
+ const editCustomSource = (source) => {
+ if (!source) return;
+ showSourceManagerDialog.value = false;
+ editingSource.value = true;
+ originalSourceUrl.value = source.url;
+ sourceName.value = source.name;
+ sourceUrl.value = source.url;
+ showSourceDialog.value = true;
+ };
+
+ const removeCustomSource = (source) => {
+ if (!source) return;
+ showSourceManagerDialog.value = false;
+ sourceToRemove.value = source;
+ showRemoveSourceDialog.value = true;
+ };
+
+ const confirmRemoveSource = () => {
+ if (sourceToRemove.value) {
+ customSources.value = customSources.value.filter(
+ (s) => s.url !== sourceToRemove.value.url,
+ );
+ saveCustomSources();
+
+ // 如果删除的是当前选中的源,切换到默认源
+ if (selectedSource.value === sourceToRemove.value.url) {
+ selectedSource.value = null;
+ localStorage.removeItem("selectedPluginSource");
+ // 重新加载插件市场数据
+ refreshPluginMarket();
+ }
+
+ toast(tm("market.sourceRemoved"), "success");
+ showRemoveSourceDialog.value = false;
+ sourceToRemove.value = null;
+ }
+ };
+
+ const saveCustomSource = () => {
+ const normalizedUrl = sourceUrl.value.trim();
+
+ if (!sourceName.value.trim() || !normalizedUrl) {
+ toast(tm("messages.fillSourceNameAndUrl"), "error");
+ return;
+ }
+
+ // 检查URL格式
+ try {
+ new URL(normalizedUrl);
+ } catch (e) {
+ toast(tm("messages.invalidUrl"), "error");
+ return;
+ }
+
+ if (editingSource.value) {
+ // 编辑模式:更新现有源
+ const index = customSources.value.findIndex(
+ (s) => s.url === originalSourceUrl.value,
+ );
+ if (index !== -1) {
+ customSources.value[index] = {
+ name: sourceName.value.trim(),
+ url: normalizedUrl,
+ };
+
+ // 如果编辑的是当前选中的源,更新选中源
+ if (selectedSource.value === originalSourceUrl.value) {
+ selectedSource.value = normalizedUrl;
+ localStorage.setItem("selectedPluginSource", selectedSource.value);
+ // 重新加载插件市场数据
+ refreshPluginMarket();
+ }
+ }
+ } else {
+ // 添加模式:检查是否已存在
+ if (customSources.value.some((source) => source.url === normalizedUrl)) {
+ toast(tm("market.sourceExists"), "error");
+ return;
+ }
+
+ customSources.value.push({
+ name: sourceName.value.trim(),
+ url: normalizedUrl,
+ });
+ }
+
+ saveCustomSources();
+ toast(
+ editingSource.value ? tm("market.sourceUpdated") : tm("market.sourceAdded"),
+ "success",
+ );
+
+ // 重置表单
+ sourceName.value = "";
+ sourceUrl.value = "";
+ editingSource.value = false;
+ originalSourceUrl.value = "";
+ showSourceDialog.value = false;
+ };
+
+ // 插件市场显示完整插件名称
+ const trimExtensionName = () => {
+ pluginMarketData.value.forEach((plugin) => {
+ if (plugin.name) {
+ let name = plugin.name.trim().toLowerCase();
+ if (name.startsWith("astrbot_plugin_")) {
+ plugin.trimmedName = name.substring(15);
+ } else if (name.startsWith("astrbot_") || name.startsWith("astrbot-")) {
+ plugin.trimmedName = name.substring(8);
+ } else plugin.trimmedName = plugin.name;
+ }
+ });
+ };
+
+ const checkAlreadyInstalled = () => {
+ const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
+ const installedRepos = new Set(data.map((ext) => ext.repo?.toLowerCase()));
+ const installedNames = new Set(data.map((ext) => ext.name));
+ const installedByRepo = new Map(
+ data
+ .filter((ext) => ext.repo)
+ .map((ext) => [ext.repo.toLowerCase(), ext]),
+ );
+ const installedByName = new Map(data.map((ext) => [ext.name, ext]));
+
+ for (let i = 0; i < pluginMarketData.value.length; i++) {
+ const plugin = pluginMarketData.value[i];
+ const matchedInstalled =
+ (plugin.repo && installedByRepo.get(plugin.repo.toLowerCase())) ||
+ installedByName.get(plugin.name);
+
+ // 兜底:市场源未提供字段时,回填本地已安装插件中的元数据,便于在市场页直接展示
+ if (matchedInstalled) {
+ if (
+ (!Array.isArray(plugin.support_platforms) ||
+ plugin.support_platforms.length === 0) &&
+ Array.isArray(matchedInstalled.support_platforms)
+ ) {
+ plugin.support_platforms = matchedInstalled.support_platforms;
+ }
+ if (!plugin.astrbot_version && matchedInstalled.astrbot_version) {
+ plugin.astrbot_version = matchedInstalled.astrbot_version;
+ }
+ }
+
+ plugin.installed =
+ installedRepos.has(plugin.repo?.toLowerCase()) ||
+ installedNames.has(plugin.name);
+ }
+
+ let installed = [];
+ let notInstalled = [];
+ for (let i = 0; i < pluginMarketData.value.length; i++) {
+ if (pluginMarketData.value[i].installed) {
+ installed.push(pluginMarketData.value[i]);
+ } else {
+ notInstalled.push(pluginMarketData.value[i]);
+ }
+ }
+ pluginMarketData.value = notInstalled.concat(installed);
+ };
+
+ const showVersionCompatibilityWarning = (message) => {
+ versionCompatibilityDialog.message = message;
+ versionCompatibilityDialog.show = true;
+ };
+
+ const continueInstallIgnoringVersionWarning = async () => {
+ versionCompatibilityDialog.show = false;
+ await newExtension(true);
+ };
+
+ const cancelInstallOnVersionWarning = () => {
+ versionCompatibilityDialog.show = false;
+ };
+
+ const newExtension = async (ignoreVersionCheck = false) => {
+ if (extension_url.value === "" && upload_file.value === null) {
+ toast(tm("messages.fillUrlOrFile"), "error");
+ return;
+ }
+
+ if (extension_url.value !== "" && upload_file.value !== null) {
+ toast(tm("messages.dontFillBoth"), "error");
+ return;
+ }
+ loading_.value = true;
+ loadingDialog.title = tm("status.loading");
+ loadingDialog.show = true;
+ if (upload_file.value !== null) {
+ toast(tm("messages.installing"), "primary");
+ const formData = new FormData();
+ formData.append("file", upload_file.value);
+ formData.append("ignore_version_check", String(ignoreVersionCheck));
+ axios
+ .post("/api/plugin/install-upload", formData, {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ })
+ .then(async (res) => {
+ loading_.value = false;
+ if (
+ res.data.status === "warning" &&
+ res.data.data?.warning_type === "astrbot_version_incompatible"
+ ) {
+ onLoadingDialogResult(2, res.data.message, -1);
+ showVersionCompatibilityWarning(res.data.message);
+ return;
+ }
+ if (res.data.status === "error") {
+ onLoadingDialogResult(2, res.data.message, -1);
+ return;
+ }
+ upload_file.value = null;
+ onLoadingDialogResult(1, res.data.message);
+ dialog.value = false;
+ await getExtensions();
+
+ viewReadme({
+ name: res.data.data.name,
+ repo: res.data.data.repo || null,
+ });
+
+ await checkAndPromptConflicts();
+ })
+ .catch((err) => {
+ loading_.value = false;
+ onLoadingDialogResult(2, err, -1);
+ });
+ } else {
+ toast(
+ tm("messages.installingFromUrl") + " " + extension_url.value,
+ "primary",
+ );
+ axios
+ .post("/api/plugin/install", {
+ url: extension_url.value,
+ proxy: getSelectedGitHubProxy(),
+ ignore_version_check: ignoreVersionCheck,
+ })
+ .then(async (res) => {
+ loading_.value = false;
+ if (
+ res.data.status === "warning" &&
+ res.data.data?.warning_type === "astrbot_version_incompatible"
+ ) {
+ onLoadingDialogResult(2, res.data.message, -1);
+ showVersionCompatibilityWarning(res.data.message);
+ return;
+ }
+ toast(res.data.message, res.data.status === "ok" ? "success" : "error");
+ if (res.data.status === "error") {
+ onLoadingDialogResult(2, res.data.message, -1);
+ return;
+ }
+ extension_url.value = "";
+ onLoadingDialogResult(1, res.data.message);
+ dialog.value = false;
+ await getExtensions();
+
+ viewReadme({
+ name: res.data.data.name,
+ repo: res.data.data.repo || null,
+ });
+
+ await checkAndPromptConflicts();
+ })
+ .catch((err) => {
+ loading_.value = false;
+ toast(tm("messages.installFailed") + " " + err, "error");
+ onLoadingDialogResult(2, err, -1);
+ });
+ }
+ };
+
+ const normalizePlatformList = (platforms) => {
+ if (!Array.isArray(platforms)) return [];
+ return platforms.filter((item) => typeof item === "string");
+ };
+
+ const getPlatformDisplayList = (platforms) => {
+ return normalizePlatformList(platforms).map((platformId) =>
+ getPlatformDisplayName(platformId),
+ );
+ };
+
+ const resolveSelectedInstallPlugin = () => {
+ if (
+ selectedMarketInstallPlugin.value &&
+ selectedMarketInstallPlugin.value.repo === extension_url.value
+ ) {
+ return selectedMarketInstallPlugin.value;
+ }
+ return pluginMarketData.value.find((plugin) => plugin.repo === extension_url.value) || null;
+ };
+
+ const selectedInstallPlugin = computed(() => resolveSelectedInstallPlugin());
+
+ const checkInstallCompatibility = async () => {
+ installCompat.checked = false;
+ installCompat.compatible = true;
+ installCompat.message = "";
+
+ const plugin = selectedInstallPlugin.value;
+ if (!plugin?.astrbot_version || uploadTab.value !== "url") {
+ return;
+ }
+
+ try {
+ const res = await axios.post("/api/plugin/check-compat", {
+ astrbot_version: plugin.astrbot_version,
+ });
+ if (res.data.status === "ok") {
+ installCompat.checked = true;
+ installCompat.compatible = !!res.data.data?.compatible;
+ installCompat.message = res.data.data?.message || "";
+ }
+ } catch (err) {
+ console.debug("Failed to check plugin compatibility:", err);
+ }
+ };
+
+ // 刷新插件市场数据
+ const refreshPluginMarket = async () => {
+ refreshingMarket.value = true;
+ try {
+ // 强制刷新插件市场数据
+ const data = await commonStore.getPluginCollections(
+ true,
+ selectedSource.value,
+ );
+ pluginMarketData.value = data;
+ trimExtensionName();
+ checkAlreadyInstalled();
+ checkUpdate();
+ refreshRandomPlugins();
+ currentPage.value = 1; // 重置到第一页
+
+ toast(tm("messages.refreshSuccess"), "success");
+ } catch (err) {
+ toast(tm("messages.refreshFailed") + " " + err, "error");
+ } finally {
+ refreshingMarket.value = false;
+ }
+ };
+
+ // 生命周期
+ onMounted(async () => {
+ if (!syncTabFromHash(getLocationHash())) {
+ if (typeof window !== "undefined") {
+ window.location.hash = `#${activeTab.value}`;
+ }
+ }
+ await getExtensions();
+
+ // 加载自定义插件源
+ loadCustomSources();
+
+ // 检查是否有 open_config 参数
+ let urlParams;
+ if (window.location.hash) {
+ // For hash mode (#/path?param=value)
+ const hashQuery = window.location.hash.split("?")[1] || "";
+ urlParams = new URLSearchParams(hashQuery);
+ } else {
+ // For history mode (/path?param=value)
+ urlParams = new URLSearchParams(window.location.search);
+ }
+ console.log("URL Parameters:", urlParams.toString());
+ const plugin_name = urlParams.get("open_config");
+ if (plugin_name) {
+ console.log(`Opening config for plugin: ${plugin_name}`);
+ openExtensionConfig(plugin_name);
+ }
+
+ try {
+ const data = await commonStore.getPluginCollections(
+ false,
+ selectedSource.value,
+ );
+ pluginMarketData.value = data;
+ trimExtensionName();
+ checkAlreadyInstalled();
+ checkUpdate();
+ refreshRandomPlugins();
+ } catch (err) {
+ toast(tm("messages.getMarketDataFailed") + " " + err, "error");
+ }
+ });
+
+ // 处理语言切换事件,重新加载插件配置以获取插件的 i18n 数据
+ const handleLocaleChange = () => {
+ // 如果配置对话框是打开的,重新加载当前插件的配置
+ if (configDialog.value && currentConfigPlugin.value) {
+ openExtensionConfig(currentConfigPlugin.value);
+ }
+ };
+
+ // 监听语言切换事件
+ window.addEventListener("astrbot-locale-changed", handleLocaleChange);
+
+ // 清理事件监听器
+ onUnmounted(() => {
+ window.removeEventListener("astrbot-locale-changed", handleLocaleChange);
+ });
+
+ // 搜索防抖处理
+ let searchDebounceTimer = null;
+ watch(marketSearch, (newVal) => {
+ if (searchDebounceTimer) {
+ clearTimeout(searchDebounceTimer);
+ }
+
+ searchDebounceTimer = setTimeout(() => {
+ debouncedMarketSearch.value = newVal;
+ // 搜索时重置到第一页
+ currentPage.value = 1;
+ }, 300); // 300ms 防抖延迟
+ });
+
+ // 监听显示模式变化并保存到 localStorage
+ watch(isListView, (newVal) => {
+ if (typeof window !== "undefined" && window.localStorage) {
+ localStorage.setItem("pluginListViewMode", String(newVal));
+ }
+ });
+
+ watch(
+ [() => dialog.value, () => extension_url.value, () => uploadTab.value],
+ async ([dialogOpen, _, currentUploadTab]) => {
+ if (!dialogOpen || currentUploadTab !== "url") {
+ installCompat.checked = false;
+ installCompat.compatible = true;
+ installCompat.message = "";
+ return;
+ }
+ await checkInstallCompatibility();
+ },
+ );
+
+ watch(
+ () => route.fullPath,
+ () => {
+ const tab = extractTabFromHash(getLocationHash());
+ if (isValidTab(tab) && tab !== activeTab.value) {
+ activeTab.value = tab;
+ }
+ },
+ );
+
+ watch(activeTab, (newTab) => {
+ if (!isValidTab(newTab)) return;
+ const currentTab = extractTabFromHash(getLocationHash());
+ if (currentTab === newTab) return;
+ const hash = getLocationHash();
+ const lastHashIndex = hash.lastIndexOf("#");
+ const nextHash =
+ lastHashIndex > 0 ? `${hash.slice(0, lastHashIndex)}#${newTab}` : `#${newTab}`;
+ if (typeof window !== "undefined") {
+ window.location.hash = nextHash;
+ }
+ });
+
+ return {
+ commonStore,
+ t,
+ tm,
+ router,
+ route,
+ getSelectedGitHubProxy,
+ conflictDialog,
+ checkAndPromptConflicts,
+ handleConflictConfirm,
+ fileInput,
+ activeTab,
+ validTabs,
+ isValidTab,
+ getLocationHash,
+ extractTabFromHash,
+ syncTabFromHash,
+ extension_data,
+ getInitialShowReserved,
+ showReserved,
+ snack_message,
+ snack_show,
+ snack_success,
+ configDialog,
+ extension_config,
+ pluginMarketData,
+ loadingDialog,
+ showPluginInfoDialog,
+ selectedPlugin,
+ curr_namespace,
+ updatingAll,
+ readmeDialog,
+ forceUpdateDialog,
+ updateAllConfirmDialog,
+ changelogDialog,
+ getInitialListViewMode,
+ isListView,
+ pluginSearch,
+ loading_,
+ currentPage,
+ dangerConfirmDialog,
+ selectedDangerPlugin,
+ selectedMarketInstallPlugin,
+ installCompat,
+ versionCompatibilityDialog,
+ showUninstallDialog,
+ pluginToUninstall,
+ showSourceDialog,
+ showSourceManagerDialog,
+ sourceName,
+ sourceUrl,
+ customSources,
+ selectedSource,
+ showRemoveSourceDialog,
+ sourceToRemove,
+ editingSource,
+ originalSourceUrl,
+ extension_url,
+ dialog,
+ upload_file,
+ uploadTab,
+ showPluginFullName,
+ marketSearch,
+ debouncedMarketSearch,
+ refreshingMarket,
+ sortBy,
+ sortOrder,
+ randomPluginNames,
+ normalizeStr,
+ toPinyinText,
+ toInitials,
+ marketCustomFilter,
+ plugin_handler_info_headers,
+ pluginHeaders,
+ filteredExtensions,
+ filteredPlugins,
+ filteredMarketPlugins,
+ sortedPlugins,
+ RANDOM_PLUGINS_COUNT,
+ randomPlugins,
+ shufflePlugins,
+ refreshRandomPlugins,
+ displayItemsPerPage,
+ totalPages,
+ paginatedPlugins,
+ updatableExtensions,
+ toggleShowReserved,
+ toast,
+ resetLoadingDialog,
+ onLoadingDialogResult,
+ failedPluginsDict,
+ getExtensions,
+ handleReloadAllFailed,
+ checkUpdate,
+ uninstallExtension,
+ handleUninstallConfirm,
+ updateExtension,
+ showUpdateAllConfirm,
+ confirmUpdateAll,
+ cancelUpdateAll,
+ confirmForceUpdate,
+ updateAllExtensions,
+ pluginOn,
+ pluginOff,
+ openExtensionConfig,
+ updateConfig,
+ showPluginInfo,
+ reloadPlugin,
+ viewReadme,
+ viewChangelog,
+ handleInstallPlugin,
+ confirmDangerInstall,
+ cancelDangerInstall,
+ loadCustomSources,
+ saveCustomSources,
+ addCustomSource,
+ openSourceManagerDialog,
+ selectPluginSource,
+ sourceSelectItems,
+ editCustomSource,
+ removeCustomSource,
+ confirmRemoveSource,
+ saveCustomSource,
+ trimExtensionName,
+ checkAlreadyInstalled,
+ showVersionCompatibilityWarning,
+ continueInstallIgnoringVersionWarning,
+ cancelInstallOnVersionWarning,
+ newExtension,
+ normalizePlatformList,
+ getPlatformDisplayList,
+ resolveSelectedInstallPlugin,
+ selectedInstallPlugin,
+ checkInstallCompatibility,
+ refreshPluginMarket,
+ handleLocaleChange,
+ searchDebounceTimer,
+ };
+};
diff --git a/dashboard/src/views/knowledge-base/DocumentDetail.vue b/dashboard/src/views/knowledge-base/DocumentDetail.vue
index fb64e628c..16d9ca67e 100644
--- a/dashboard/src/views/knowledge-base/DocumentDetail.vue
+++ b/dashboard/src/views/knowledge-base/DocumentDetail.vue
@@ -244,10 +244,13 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
+import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog'
const { tm: t } = useModuleI18n('features/knowledge-base/document')
const route = useRoute()
+const confirmDialog = useConfirmDialog()
+
const kbId = ref(route.params.kbId as string)
const docId = ref(route.params.docId as string)
@@ -356,7 +359,7 @@ const viewChunk = (chunk: any) => {
// 删除分块
const deleteChunk = async (chunk: any) => {
- if (!confirm(t('chunks.deleteConfirm'))) return
+ if (!(await askForConfirmation(t('chunks.deleteConfirm'), confirmDialog))) return
try {
const response = await axios.post('/api/kb/chunk/delete', {
chunk_id: chunk.chunk_id,
diff --git a/dashboard/src/views/persona/PersonaManager.vue b/dashboard/src/views/persona/PersonaManager.vue
index d8cf0dfc0..b29f57715 100644
--- a/dashboard/src/views/persona/PersonaManager.vue
+++ b/dashboard/src/views/persona/PersonaManager.vue
@@ -110,14 +110,25 @@
+ @saved="handlePersonaSaved" @deleted="handlePersonaDeleted" @error="showError" />
{{ viewingPersona.persona_id }}
-
+
+
+ {{ tm('buttons.edit') }}
+
+
+
@@ -260,6 +271,10 @@ import PersonaCard from './PersonaCard.vue';
import PersonaForm from '@/components/shared/PersonaForm.vue';
import CreateFolderDialog from './CreateFolderDialog.vue';
import MoveToFolderDialog from './MoveToFolderDialog.vue';
+import {
+ askForConfirmation as askForConfirmationDialog,
+ useConfirmDialog
+} from '@/utils/confirmDialog';
import type { Folder, FolderTreeNode } from '@/components/folder/types';
@@ -294,7 +309,8 @@ export default defineComponent({
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/persona');
- return { t, tm };
+ const confirmDialog = useConfirmDialog();
+ return { t, tm, confirmDialog };
},
data() {
return {
@@ -409,13 +425,30 @@ export default defineComponent({
this.showViewDialog = true;
},
+ openEditFromViewDialog() {
+ if (!this.viewingPersona) return;
+ this.editingPersona = this.viewingPersona;
+ this.showViewDialog = false;
+ this.showPersonaDialog = true;
+ },
+
handlePersonaSaved(message: string) {
this.showSuccess(message);
this.refreshCurrentFolder();
},
+ handlePersonaDeleted(message: string) {
+ this.showSuccess(message);
+ this.refreshCurrentFolder();
+ },
+
async confirmDeletePersona(persona: Persona) {
- if (!confirm(this.tm('messages.deleteConfirm', { id: persona.persona_id }))) {
+ if (
+ !(await askForConfirmationDialog(
+ this.tm('messages.deleteConfirm', { id: persona.persona_id }),
+ this.confirmDialog,
+ ))
+ ) {
return;
}
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
index b000b15f2..7820a40b1 100644
--- a/dashboard/tsconfig.json
+++ b/dashboard/tsconfig.json
@@ -1,31 +1,15 @@
{
+ "extends": "@vue/tsconfig/tsconfig.dom.json",
+ "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/types/.d.ts"],
"compilerOptions": {
- "target": "ESNext",
- "useDefineForClassFields": true,
- "module": "ESNext",
- "moduleResolution": "Node",
- "strict": true,
- "jsx": "preserve",
- "sourceMap": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "esModuleInterop": true,
- "lib": ["ESNext", "DOM"],
- "skipLibCheck": true,
- "noEmit": true,
+ "ignoreDeprecations": "5.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
- "allowJs": true,
- "ignoreDeprecations": "5.0"
+ "allowJs": true
},
- "include": [
- "env.d.ts",
- "src/**/*",
- "src/**/*.vue",
- "src/types/.d.ts"
- ],
+
"references": [
{
"path": "./tsconfig.vite-config.json"
diff --git a/dashboard/tsconfig.vite-config.json b/dashboard/tsconfig.vite-config.json
index 85453b690..a3d4b2151 100644
--- a/dashboard/tsconfig.vite-config.json
+++ b/dashboard/tsconfig.vite-config.json
@@ -1,11 +1,9 @@
{
+ "extends": "@vue/tsconfig/tsconfig.json",
+ "include": ["vite.config.*"],
"compilerOptions": {
"composite": true,
- "module": "ESNext",
- "moduleResolution": "Node",
- "allowSyntheticDefaultImports": true,
"allowJs": true,
"types": ["node"]
- },
- "include": ["vite.config.*"]
+ }
}
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts
index a5168eef7..b53e0310d 100644
--- a/dashboard/vite.config.ts
+++ b/dashboard/vite.config.ts
@@ -1,7 +1,7 @@
-import { fileURLToPath, URL } from "url";
-import { defineConfig } from "vite";
-import vue from "@vitejs/plugin-vue";
-import vuetify from "vite-plugin-vuetify";
+import { fileURLToPath, URL } from 'url';
+import { defineConfig } from 'vite';
+import vue from '@vitejs/plugin-vue';
+import vuetify from 'vite-plugin-vuetify';
// https://vitejs.dev/config/
export default defineConfig({
@@ -9,40 +9,42 @@ export default defineConfig({
vue({
template: {
compilerOptions: {
- isCustomElement: (tag) => ["v-list-recognize-title"].includes(tag),
- },
- },
+ isCustomElement: (tag) => ['v-list-recognize-title'].includes(tag)
+ }
+ }
}),
vuetify({
- autoImport: true,
- }),
+ autoImport: true
+ })
],
resolve: {
alias: {
- mermaid: "mermaid/dist/mermaid.js",
- "@": fileURLToPath(new URL("./src", import.meta.url)),
- },
+ mermaid: 'mermaid/dist/mermaid.js',
+ '@': fileURLToPath(new URL('./src', import.meta.url))
+ }
},
css: {
preprocessorOptions: {
- scss: {},
- },
+ scss: {}
+ }
},
build: {
- chunkSizeWarningLimit: 1024 * 1024, // Set the limit to 1 MB
+ sourcemap: false,
+ chunkSizeWarningLimit: 1024 * 1024 // Set the limit to 1 MB
},
optimizeDeps: {
- exclude: ["vuetify"],
- entries: ["./src/**/*.vue"],
+ exclude: ['vuetify'],
+ entries: ['./src/**/*.vue']
},
server: {
- host: "::",
+ host: '0.0.0.0',
port: 3000,
proxy: {
- "/api": {
- target: "http://127.0.0.1:6185/",
+ '/api': {
+ target: 'http://127.0.0.1:6185/',
changeOrigin: true,
- },
- },
- },
+ ws: true
+ }
+ }
+ }
});
diff --git a/main.py b/main.py
index 60879f065..36c46fca3 100644
--- a/main.py
+++ b/main.py
@@ -5,11 +5,26 @@ import os
import sys
from pathlib import Path
-from astrbot.core import LogBroker, LogManager, db_helper, logger
-from astrbot.core.config.default import VERSION
-from astrbot.core.initial_loader import InitialLoader
-from astrbot.core.utils.astrbot_path import get_astrbot_data_path
-from astrbot.core.utils.io import download_dashboard, get_dashboard_version
+import runtime_bootstrap
+
+runtime_bootstrap.initialize_runtime_bootstrap()
+
+from astrbot.core import LogBroker, LogManager, db_helper, logger # noqa: E402
+from astrbot.core.config.default import VERSION # noqa: E402
+from astrbot.core.initial_loader import InitialLoader # noqa: E402
+from astrbot.core.utils.astrbot_path import ( # noqa: E402
+ get_astrbot_config_path,
+ get_astrbot_data_path,
+ get_astrbot_knowledge_base_path,
+ get_astrbot_plugin_path,
+ get_astrbot_root,
+ get_astrbot_site_packages_path,
+ get_astrbot_temp_path,
+)
+from astrbot.core.utils.io import ( # noqa: E402
+ download_dashboard,
+ get_dashboard_version,
+)
# 将父目录添加到 sys.path
sys.path.append(Path(__file__).parent.as_posix())
@@ -25,14 +40,24 @@ logo_tmpl = r"""
"""
-def check_env():
+def check_env() -> None:
if not (sys.version_info.major == 3 and sys.version_info.minor >= 10):
logger.error("请使用 Python3.10+ 运行本项目。")
exit()
- os.makedirs("data/config", exist_ok=True)
- os.makedirs("data/plugins", exist_ok=True)
- os.makedirs("data/temp", exist_ok=True)
+ astrbot_root = get_astrbot_root()
+ if astrbot_root not in sys.path:
+ sys.path.insert(0, astrbot_root)
+
+ site_packages_path = get_astrbot_site_packages_path()
+ if site_packages_path not in sys.path:
+ sys.path.insert(0, site_packages_path)
+
+ os.makedirs(get_astrbot_config_path(), exist_ok=True)
+ os.makedirs(get_astrbot_plugin_path(), exist_ok=True)
+ os.makedirs(get_astrbot_temp_path(), exist_ok=True)
+ os.makedirs(get_astrbot_knowledge_base_path(), exist_ok=True)
+ os.makedirs(site_packages_path, exist_ok=True)
# 针对问题 #181 的临时解决方案
mimetypes.add_type("text/javascript", ".js")
diff --git a/openapi.json b/openapi.json
new file mode 100644
index 000000000..2fadecbc0
--- /dev/null
+++ b/openapi.json
@@ -0,0 +1,685 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "AstrBot Open API",
+ "version": "1.0.0",
+ "description": "Developer HTTP APIs for AstrBot. Use API Key authentication for /api/v1/* endpoints."
+ },
+ "servers": [
+ {
+ "url": "http://localhost:6185"
+ }
+ ],
+ "tags": [
+ {
+ "name": "Open API",
+ "description": "Developer APIs authenticated by API Key"
+ }
+ ],
+ "paths": {
+ "/api/v1/im/bots": {
+ "get": {
+ "tags": [
+ "Open API"
+ ],
+ "summary": "List bot IDs",
+ "description": "Returns configured bot/platform IDs.",
+ "security": [
+ {
+ "ApiKeyHeader": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseBotList"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ }
+ }
+ }
+ },
+ "/api/v1/file": {
+ "post": {
+ "tags": [
+ "Open API"
+ ],
+ "summary": "Upload attachment file",
+ "description": "Upload a file and get attachment_id for later use in chat/message APIs.",
+ "security": [
+ {
+ "ApiKeyHeader": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "multipart/form-data": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "file"
+ ],
+ "properties": {
+ "file": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseUpload"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ }
+ }
+ }
+ },
+ "/api/v1/chat": {
+ "post": {
+ "tags": [
+ "Open API"
+ ],
+ "summary": "Send chat message (SSE)",
+ "description": "Send message to AstrBot chat pipeline and receive streaming SSE response. Reuses /api/chat/send behavior. If session_id/conversation_id is omitted, server will create a new UUID session_id.",
+ "security": [
+ {
+ "ApiKeyHeader": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ChatSendRequest"
+ },
+ "examples": {
+ "plain": {
+ "value": {
+ "message": "Hello",
+ "username": "alice",
+ "session_id": "my_session_001",
+ "enable_streaming": true
+ }
+ },
+ "multipartMessage": {
+ "value": {
+ "message": [
+ {
+ "type": "plain",
+ "text": "Please analyze this file"
+ },
+ {
+ "type": "file",
+ "attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111"
+ }
+ ],
+ "username": "alice",
+ "session_id": "my_session_001",
+ "selected_provider": "openai_chat_completion",
+ "selected_model": "gpt-4.1-mini",
+ "enable_streaming": true
+ }
+ },
+ "withConfig": {
+ "value": {
+ "message": "Use a specific config for this session",
+ "username": "alice",
+ "session_id": "my_session_001",
+ "config_id": "default",
+ "enable_streaming": true
+ }
+ },
+ "autoSessionWithUsername": {
+ "value": {
+ "message": "hello",
+ "username": "alice",
+ "enable_streaming": true
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "SSE stream",
+ "content": {
+ "text/event-stream": {
+ "schema": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ }
+ }
+ }
+ },
+ "/api/v1/chat/sessions": {
+ "get": {
+ "tags": [
+ "Open API"
+ ],
+ "summary": "List chat sessions with pagination",
+ "description": "List chat sessions for the specified username.",
+ "security": [
+ {
+ "ApiKeyHeader": []
+ }
+ ],
+ "parameters": [
+ {
+ "name": "page",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "default": 1,
+ "minimum": 1
+ }
+ },
+ {
+ "name": "page_size",
+ "in": "query",
+ "schema": {
+ "type": "integer",
+ "default": 20,
+ "minimum": 1,
+ "maximum": 100
+ }
+ },
+ {
+ "name": "platform_id",
+ "in": "query",
+ "schema": {
+ "type": "string"
+ },
+ "description": "Optional platform filter"
+ },
+ {
+ "name": "username",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "Target username."
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseChatSessions"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ }
+ }
+ }
+ },
+ "/api/v1/im/message": {
+ "post": {
+ "tags": [
+ "Open API"
+ ],
+ "summary": "Send proactive message to a platform bot",
+ "description": "Send message directly to platform bot by umo + message chain payload.",
+ "security": [
+ {
+ "ApiKeyHeader": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SendMessageRequest"
+ },
+ "examples": {
+ "plain": {
+ "value": {
+ "umo": "webchat:FriendMessage:openapi_probe",
+ "message": "ping from api key"
+ }
+ },
+ "chain": {
+ "value": {
+ "umo": "webchat:FriendMessage:openapi_probe",
+ "message": [
+ {
+ "type": "plain",
+ "text": "hello"
+ },
+ {
+ "type": "image",
+ "attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseEmpty"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ }
+ }
+ }
+ },
+ "/api/v1/configs": {
+ "get": {
+ "tags": [
+ "Open API"
+ ],
+ "summary": "List available chat config files",
+ "description": "Returns all available AstrBot config files that can be selected by Chat API using config_id/config_name.",
+ "security": [
+ {
+ "ApiKeyHeader": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiResponseChatConfigList"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "403": {
+ "$ref": "#/components/responses/Forbidden"
+ }
+ }
+ }
+ }
+ },
+ "components": {
+ "securitySchemes": {
+ "ApiKeyHeader": {
+ "type": "apiKey",
+ "in": "header",
+ "name": "X-API-Key",
+ "description": "Open API key. Authorization: Bearer is also accepted."
+ }
+ },
+ "responses": {
+ "Unauthorized": {
+ "description": "Unauthorized"
+ },
+ "Forbidden": {
+ "description": "Forbidden"
+ }
+ },
+ "schemas": {
+ "ApiResponseEmpty": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "ok"
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "data": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
+ "ApiResponseBotList": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "ok"
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "bot_ids": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "ApiResponseUpload": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "ok"
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "attachment_id": {
+ "type": "string"
+ },
+ "filename": {
+ "type": "string"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "ApiResponseChatSessions": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "ok"
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "sessions": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ChatSessionItem"
+ }
+ },
+ "page": {
+ "type": "integer"
+ },
+ "page_size": {
+ "type": "integer"
+ },
+ "total": {
+ "type": "integer"
+ }
+ }
+ }
+ }
+ },
+ "ChatSessionItem": {
+ "type": "object",
+ "properties": {
+ "session_id": {
+ "type": "string"
+ },
+ "platform_id": {
+ "type": "string"
+ },
+ "creator": {
+ "type": "string"
+ },
+ "display_name": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "is_group": {
+ "type": "integer"
+ },
+ "created_at": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "updated_at": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ },
+ "MessagePart": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": [
+ "plain",
+ "reply",
+ "image",
+ "record",
+ "file",
+ "video"
+ ]
+ },
+ "text": {
+ "type": "string"
+ },
+ "message_id": {
+ "type": [
+ "string",
+ "integer"
+ ]
+ },
+ "selected_text": {
+ "type": "string"
+ },
+ "attachment_id": {
+ "type": "string"
+ },
+ "filename": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "type"
+ ]
+ },
+ "ChatSendRequest": {
+ "type": "object",
+ "required": [
+ "message",
+ "username"
+ ],
+ "properties": {
+ "message": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/MessagePart"
+ }
+ }
+ ]
+ },
+ "session_id": {
+ "type": "string",
+ "description": "Optional chat session ID. If omitted (and conversation_id is also omitted), server creates a UUID automatically."
+ },
+ "conversation_id": {
+ "type": "string",
+ "description": "Alias of session_id."
+ },
+ "username": {
+ "type": "string",
+ "description": "Target username."
+ },
+ "selected_provider": {
+ "type": "string"
+ },
+ "selected_model": {
+ "type": "string"
+ },
+ "enable_streaming": {
+ "type": "boolean",
+ "default": true
+ },
+ "config_id": {
+ "type": "string",
+ "description": "Optional AstrBot config file ID. If provided, the chat session will use this config file. Use \"default\" to reset to default config."
+ },
+ "config_name": {
+ "type": "string",
+ "description": "Optional AstrBot config file name. Used only when config_id is not provided."
+ }
+ }
+ },
+ "SendMessageRequest": {
+ "type": "object",
+ "required": [
+ "umo",
+ "message"
+ ],
+ "properties": {
+ "umo": {
+ "type": "string",
+ "description": "Unified message origin. Format: platform:message_type:session_id"
+ },
+ "message": {
+ "oneOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/MessagePart"
+ }
+ }
+ ]
+ }
+ }
+ },
+ "ChatConfigFile": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "is_default": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "id",
+ "name",
+ "path",
+ "is_default"
+ ]
+ },
+ "ApiResponseChatConfigList": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string",
+ "example": "ok"
+ },
+ "message": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "configs": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ChatConfigFile"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/pyproject.toml b/pyproject.toml
index e19fbcf00..d0845cac8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,9 +1,9 @@
[project]
name = "AstrBot"
-version = "4.14.4"
+version = "4.18.3"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
-requires-python = ">=3.10"
+requires-python = ">=3.12"
keywords = ["Astrbot", "Astrbot Module", "Astrbot Plugin"]
@@ -17,14 +17,14 @@ dependencies = [
"beautifulsoup4>=4.13.4",
"certifi>=2025.4.26",
"chardet~=5.1.0",
- "colorlog>=6.9.0",
+ "loguru>=0.7.2",
"cryptography>=44.0.3",
"dashscope>=1.23.2",
"defusedxml>=0.7.1",
"deprecated>=1.2.18",
"dingtalk-stream>=0.22.1",
"docstring-parser>=0.16",
- "faiss-cpu==1.10.0",
+ "faiss-cpu>=1.12.0",
"filelock>=3.18.0",
"google-genai>=1.56.0",
"lark-oapi>=1.4.15",
@@ -36,7 +36,7 @@ dependencies = [
"pip>=25.1.1",
"psutil>=5.8.0,<7.2.0",
"py-cord>=2.6.1",
- "pydantic~=2.10.3",
+ "pydantic>=2.12.5",
"pydub>=0.25.1",
"pyjwt>=2.10.1",
"python-telegram-bot>=22.0",
@@ -61,7 +61,8 @@ dependencies = [
"xinference-client",
"tenacity>=9.1.2",
"shipyard-python-sdk>=0.2.4",
- "quart-cors>=0.8.0",
+ "python-socks>=2.8.0",
+ "packaging>=24.2",
]
[dependency-groups]
diff --git a/requirements.txt b/requirements.txt
index 1221275ec..dd19a02c3 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,14 +10,14 @@ apscheduler>=3.11.0
beautifulsoup4>=4.13.4
certifi>=2025.4.26
chardet~=5.1.0
-colorlog>=6.9.0
+loguru>=0.7.2
cryptography>=44.0.3
dashscope>=1.23.2
defusedxml>=0.7.1
deprecated>=1.2.18
dingtalk-stream>=0.22.1
docstring-parser>=0.16
-faiss-cpu==1.10.0
+faiss-cpu>=1.12.0
filelock>=3.18.0
google-genai>=1.56.0
lark-oapi>=1.4.15
@@ -29,7 +29,7 @@ pillow>=11.2.1
pip>=25.1.1
psutil>=5.8.0,<7.2.0
py-cord>=2.6.1
-pydantic~=2.10.3
+pydantic>=2.12.5
pydub>=0.25.1
pyjwt>=2.10.1
python-telegram-bot>=22.0
@@ -53,4 +53,5 @@ jieba>=0.42.1
markitdown-no-magika[docx,xls,xlsx]>=0.1.2
xinference-client
tenacity>=9.1.2
-shipyard-python-sdk>=0.2.4
\ No newline at end of file
+shipyard-python-sdk>=0.2.4
+packaging>=24.2
diff --git a/runtime_bootstrap.py b/runtime_bootstrap.py
new file mode 100644
index 000000000..1e9d109d6
--- /dev/null
+++ b/runtime_bootstrap.py
@@ -0,0 +1,50 @@
+import logging
+import ssl
+from typing import Any
+
+import aiohttp.connector as aiohttp_connector
+
+from astrbot.utils.http_ssl_common import build_ssl_context_with_certifi
+
+logger = logging.getLogger(__name__)
+
+
+def _try_patch_aiohttp_ssl_context(
+ ssl_context: ssl.SSLContext,
+ log_obj: Any | None = None,
+) -> bool:
+ log = log_obj or logger
+ attr_name = "_SSL_CONTEXT_VERIFIED"
+
+ if not hasattr(aiohttp_connector, attr_name):
+ log.warning(
+ "aiohttp connector does not expose _SSL_CONTEXT_VERIFIED; skipped patch.",
+ )
+ return False
+
+ current_value = getattr(aiohttp_connector, attr_name, None)
+ if current_value is not None and not isinstance(current_value, ssl.SSLContext):
+ log.warning(
+ "aiohttp connector exposes _SSL_CONTEXT_VERIFIED with unexpected type; skipped patch.",
+ )
+ return False
+
+ setattr(aiohttp_connector, attr_name, ssl_context)
+ log.info("Configured aiohttp verified SSL context with system+certifi trust chain.")
+ return True
+
+
+def configure_runtime_ca_bundle(log_obj: Any | None = None) -> bool:
+ log = log_obj or logger
+
+ try:
+ log.info("Bootstrapping runtime CA bundle.")
+ ssl_context = build_ssl_context_with_certifi(log_obj=log)
+ return _try_patch_aiohttp_ssl_context(ssl_context, log_obj=log)
+ except Exception as exc:
+ log.error("Failed to configure runtime CA bundle for aiohttp: %r", exc)
+ return False
+
+
+def initialize_runtime_bootstrap(log_obj: Any | None = None) -> bool:
+ return configure_runtime_ca_bundle(log_obj=log_obj)
diff --git a/scripts/generate_changelog.py b/scripts/generate_changelog.py
index 446e0bc56..75b6ca88c 100755
--- a/scripts/generate_changelog.py
+++ b/scripts/generate_changelog.py
@@ -185,7 +185,7 @@ def generate_simple_changelog(commits):
return changelog_zh + changelog_en
-def main():
+def main() -> None:
parser = argparse.ArgumentParser(description="Generate changelog from git commits")
parser.add_argument(
"--version", help="Version number for the changelog (e.g., v4.13.3)"
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000..b9807c1de
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,381 @@
+"""
+AstrBot 测试配置
+
+提供共享的 pytest fixtures 和测试工具。
+"""
+
+import json
+import os
+import sys
+from asyncio import Queue
+from pathlib import Path
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+import pytest_asyncio
+
+# 使用 tests/fixtures/helpers.py 中的共享工具函数,避免重复定义
+from tests.fixtures.helpers import create_mock_llm_response, create_mock_message_component
+
+# 将项目根目录添加到 sys.path
+PROJECT_ROOT = Path(__file__).parent.parent
+if str(PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(PROJECT_ROOT))
+
+# 设置测试环境变量
+os.environ.setdefault("TESTING", "true")
+os.environ.setdefault("ASTRBOT_TEST_MODE", "true")
+
+
+# ============================================================
+# 测试收集和排序
+# ============================================================
+
+
+def pytest_collection_modifyitems(session, config, items): # noqa: ARG001
+ """重新排序测试:单元测试优先,集成测试在后。"""
+ unit_tests = []
+ integration_tests = []
+ deselected = []
+ profile = config.getoption("--test-profile") or os.environ.get(
+ "ASTRBOT_TEST_PROFILE", "all"
+ )
+
+ for item in items:
+ item_path = Path(str(item.path))
+ is_integration = "integration" in item_path.parts
+
+ if is_integration:
+ if item.get_closest_marker("integration") is None:
+ item.add_marker(pytest.mark.integration)
+ item.add_marker(pytest.mark.tier_d)
+ integration_tests.append(item)
+ else:
+ if item.get_closest_marker("unit") is None:
+ item.add_marker(pytest.mark.unit)
+ if any(
+ item.get_closest_marker(marker) is not None
+ for marker in ("platform", "provider", "slow")
+ ):
+ item.add_marker(pytest.mark.tier_c)
+ unit_tests.append(item)
+
+ # 单元测试 -> 集成测试
+ ordered_items = unit_tests + integration_tests
+ if profile == "blocking":
+ selected_items = []
+ for item in ordered_items:
+ if item.get_closest_marker("tier_c") or item.get_closest_marker("tier_d"):
+ deselected.append(item)
+ else:
+ selected_items.append(item)
+ if deselected:
+ config.hook.pytest_deselected(items=deselected)
+ items[:] = selected_items
+ return
+
+ items[:] = ordered_items
+
+
+def pytest_addoption(parser):
+ """增加测试执行档位选择。"""
+ parser.addoption(
+ "--test-profile",
+ action="store",
+ default=None,
+ choices=["all", "blocking"],
+ help="Select test profile. 'blocking' excludes auto-classified tier_c/tier_d tests.",
+ )
+
+
+def pytest_configure(config):
+ """注册自定义标记。"""
+ config.addinivalue_line("markers", "unit: 单元测试")
+ config.addinivalue_line("markers", "integration: 集成测试")
+ config.addinivalue_line("markers", "slow: 慢速测试")
+ config.addinivalue_line("markers", "platform: 平台适配器测试")
+ config.addinivalue_line("markers", "provider: LLM Provider 测试")
+ config.addinivalue_line("markers", "db: 数据库相关测试")
+ config.addinivalue_line("markers", "tier_c: C-tier tests (optional / non-blocking)")
+ config.addinivalue_line("markers", "tier_d: D-tier tests (extended / integration)")
+
+
+# ============================================================
+# 临时目录和文件 Fixtures
+# ============================================================
+
+
+@pytest.fixture
+def temp_dir(tmp_path: Path) -> Path:
+ """创建临时目录用于测试。"""
+ return tmp_path
+
+
+@pytest.fixture
+def event_queue() -> Queue:
+ """Create a shared asyncio queue fixture for tests."""
+ return Queue()
+
+
+@pytest.fixture
+def platform_settings() -> dict:
+ """Create a shared empty platform settings fixture for adapter tests."""
+ return {}
+
+
+@pytest.fixture
+def temp_data_dir(temp_dir: Path) -> Path:
+ """创建模拟的 data 目录结构。"""
+ data_dir = temp_dir / "data"
+ data_dir.mkdir()
+
+ # 创建必要的子目录
+ (data_dir / "config").mkdir()
+ (data_dir / "plugins").mkdir()
+ (data_dir / "temp").mkdir()
+ (data_dir / "attachments").mkdir()
+
+ return data_dir
+
+
+@pytest.fixture
+def temp_config_file(temp_data_dir: Path) -> Path:
+ """创建临时配置文件。"""
+ config_path = temp_data_dir / "config" / "cmd_config.json"
+ default_config = {
+ "provider": [],
+ "platform": [],
+ "provider_settings": {},
+ "default_personality": None,
+ "timezone": "Asia/Shanghai",
+ }
+ config_path.write_text(json.dumps(default_config, indent=2), encoding="utf-8")
+ return config_path
+
+
+@pytest.fixture
+def temp_db_file(temp_data_dir: Path) -> Path:
+ """创建临时数据库文件路径。"""
+ return temp_data_dir / "test.db"
+
+
+# ============================================================
+# Mock Fixtures
+# ============================================================
+
+
+@pytest.fixture
+def mock_provider():
+ """创建模拟的 Provider。"""
+ provider = MagicMock()
+ provider.provider_config = {
+ "id": "test-provider",
+ "type": "openai_chat_completion",
+ "model": "gpt-4o-mini",
+ }
+ provider.get_model = MagicMock(return_value="gpt-4o-mini")
+ provider.text_chat = AsyncMock()
+ provider.text_chat_stream = AsyncMock()
+ provider.terminate = AsyncMock()
+ return provider
+
+
+@pytest.fixture
+def mock_platform():
+ """创建模拟的 Platform。"""
+ platform = MagicMock()
+ platform.platform_name = "test_platform"
+ platform.platform_meta = MagicMock()
+ platform.platform_meta.support_proactive_message = False
+ platform.send_message = AsyncMock()
+ platform.terminate = AsyncMock()
+ return platform
+
+
+@pytest.fixture
+def mock_conversation():
+ """创建模拟的 Conversation。"""
+ from astrbot.core.db.po import ConversationV2
+
+ return ConversationV2(
+ conversation_id="test-conv-id",
+ platform_id="test_platform",
+ user_id="test_user",
+ content=[],
+ persona_id=None,
+ )
+
+
+@pytest.fixture
+def mock_event():
+ """创建模拟的 AstrMessageEvent。"""
+ event = MagicMock()
+ event.unified_msg_origin = "test_umo"
+ event.session_id = "test_session"
+ event.message_str = "Hello, world!"
+ event.message_obj = MagicMock()
+ event.message_obj.message = []
+ event.message_obj.sender = MagicMock()
+ event.message_obj.sender.user_id = "test_user"
+ event.message_obj.sender.nickname = "Test User"
+ event.message_obj.group_id = None
+ event.message_obj.group = None
+ event.get_platform_name = MagicMock(return_value="test_platform")
+ event.get_platform_id = MagicMock(return_value="test_platform")
+ event.get_group_id = MagicMock(return_value=None)
+ event.get_extra = MagicMock(return_value=None)
+ event.set_extra = MagicMock()
+ event.trace = MagicMock()
+ event.platform_meta = MagicMock()
+ event.platform_meta.support_proactive_message = False
+ return event
+
+
+# ============================================================
+# 配置 Fixtures
+# ============================================================
+
+
+@pytest.fixture
+def astrbot_config(temp_config_file: Path):
+ """创建 AstrBotConfig 实例。"""
+ from astrbot.core.config.astrbot_config import AstrBotConfig
+
+ config = AstrBotConfig()
+ config._config_path = str(temp_config_file) # noqa: SLF001
+ return config
+
+
+@pytest.fixture
+def main_agent_build_config():
+ """创建 MainAgentBuildConfig 实例。"""
+ from astrbot.core.astr_main_agent import MainAgentBuildConfig
+
+ return MainAgentBuildConfig(
+ tool_call_timeout=60,
+ tool_schema_mode="full",
+ provider_wake_prefix="",
+ streaming_response=True,
+ sanitize_context_by_modalities=False,
+ kb_agentic_mode=False,
+ file_extract_enabled=False,
+ context_limit_reached_strategy="truncate_by_turns",
+ llm_safety_mode=True,
+ computer_use_runtime="local",
+ add_cron_tools=True,
+ )
+
+
+# ============================================================
+# 数据库 Fixtures
+# ============================================================
+
+
+@pytest_asyncio.fixture
+async def temp_db(temp_db_file: Path):
+ """创建临时数据库实例。"""
+ from astrbot.core.db.sqlite import SQLiteDatabase
+
+ db = SQLiteDatabase(str(temp_db_file))
+ try:
+ yield db
+ finally:
+ await db.engine.dispose()
+ if temp_db_file.exists():
+ temp_db_file.unlink()
+
+
+# ============================================================
+# Context Fixtures
+# ============================================================
+
+
+@pytest_asyncio.fixture
+async def mock_context(
+ astrbot_config,
+ temp_db,
+ mock_provider,
+ mock_platform,
+):
+ """创建模拟的插件上下文。"""
+ from asyncio import Queue
+
+ from astrbot.core.star.context import Context
+
+ event_queue = Queue()
+
+ provider_manager = MagicMock()
+ provider_manager.get_using_provider = MagicMock(return_value=mock_provider)
+ provider_manager.get_provider_by_id = MagicMock(return_value=mock_provider)
+
+ platform_manager = MagicMock()
+ conversation_manager = MagicMock()
+ message_history_manager = MagicMock()
+ persona_manager = MagicMock()
+ persona_manager.personas_v3 = []
+ astrbot_config_mgr = MagicMock()
+ knowledge_base_manager = MagicMock()
+ cron_manager = MagicMock()
+ subagent_orchestrator = None
+
+ context = Context(
+ event_queue,
+ astrbot_config,
+ temp_db,
+ provider_manager,
+ platform_manager,
+ conversation_manager,
+ message_history_manager,
+ persona_manager,
+ astrbot_config_mgr,
+ knowledge_base_manager,
+ cron_manager,
+ subagent_orchestrator,
+ )
+
+ return context
+
+
+# ============================================================
+# Provider Request Fixtures
+# ============================================================
+
+
+@pytest.fixture
+def provider_request():
+ """创建 ProviderRequest 实例。"""
+ from astrbot.core.provider.entities import ProviderRequest
+
+ return ProviderRequest(
+ prompt="Hello",
+ session_id="test_session",
+ image_urls=[],
+ contexts=[],
+ system_prompt="You are a helpful assistant.",
+ )
+
+
+# ============================================================
+# 跳过条件
+# ============================================================
+
+
+def pytest_runtest_setup(item):
+ """在测试运行前检查跳过条件。"""
+ # 跳过需要 API Key 但未设置的 Provider 测试
+ if item.get_closest_marker("provider"):
+ if not os.environ.get("TEST_PROVIDER_API_KEY"):
+ pytest.skip("TEST_PROVIDER_API_KEY not set")
+
+ # 跳过需要特定平台的测试
+ if item.get_closest_marker("platform"):
+ required_platform = None
+ marker = item.get_closest_marker("platform")
+ if marker and marker.args:
+ required_platform = marker.args[0]
+
+ if required_platform and not os.environ.get(
+ f"TEST_{required_platform.upper()}_ENABLED"
+ ):
+ pytest.skip(f"TEST_{required_platform.upper()}_ENABLED not set")
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
new file mode 100644
index 000000000..16e927d2c
--- /dev/null
+++ b/tests/fixtures/__init__.py
@@ -0,0 +1,64 @@
+"""
+AstrBot 测试数据
+
+此目录存放测试用的静态数据和配置文件。
+
+目录结构:
+- fixtures/
+ ├── configs/ # 测试配置文件
+ ├── messages/ # 测试消息数据
+ ├── plugins/ # 测试插件
+ ├── knowledge_base/ # 测试知识库数据
+ ├── mocks/ # Mock 模块
+ └── helpers.py # 辅助函数
+"""
+
+import json
+from pathlib import Path
+
+from .helpers import (
+ NoopAwaitable,
+ create_mock_discord_attachment,
+ create_mock_discord_channel,
+ create_mock_discord_user,
+ create_mock_file,
+ create_mock_llm_response,
+ create_mock_message_component,
+ create_mock_update,
+ make_platform_config,
+)
+
+FIXTURES_DIR = Path(__file__).parent
+
+
+def load_fixture(filename: str) -> dict:
+ """加载 JSON 格式的测试数据。"""
+ filepath = FIXTURES_DIR / filename
+ if not filepath.exists():
+ raise FileNotFoundError(f"Fixture not found: {filepath}")
+ return json.loads(filepath.read_text(encoding="utf-8"))
+
+
+def get_fixture_path(filename: str) -> Path:
+ """获取测试数据文件路径。"""
+ filepath = FIXTURES_DIR / filename
+ if not filepath.exists():
+ raise FileNotFoundError(f"Fixture not found: {filepath}")
+ return filepath
+
+
+__all__ = [
+ "FIXTURES_DIR",
+ "load_fixture",
+ "get_fixture_path",
+ # 辅助函数
+ "NoopAwaitable",
+ "make_platform_config",
+ "create_mock_update",
+ "create_mock_file",
+ "create_mock_discord_attachment",
+ "create_mock_discord_user",
+ "create_mock_discord_channel",
+ "create_mock_message_component",
+ "create_mock_llm_response",
+]
diff --git a/tests/fixtures/configs/test_cmd_config.json b/tests/fixtures/configs/test_cmd_config.json
new file mode 100644
index 000000000..2b92302a4
--- /dev/null
+++ b/tests/fixtures/configs/test_cmd_config.json
@@ -0,0 +1,21 @@
+{
+ "provider": [
+ {
+ "id": "test-openai",
+ "type": "openai_chat_completion",
+ "model": "gpt-4o-mini",
+ "key": ["test-key"]
+ }
+ ],
+ "platform": [],
+ "provider_settings": {
+ "default_personality": null,
+ "prompt_prefix": "",
+ "image_caption_provider_id": "",
+ "datetime_system_prompt": true,
+ "identifier": true,
+ "group_name_display": true
+ },
+ "default_personality": null,
+ "timezone": "Asia/Shanghai"
+}
diff --git a/tests/fixtures/helpers.py b/tests/fixtures/helpers.py
new file mode 100644
index 000000000..8f64ab6c9
--- /dev/null
+++ b/tests/fixtures/helpers.py
@@ -0,0 +1,332 @@
+"""测试辅助函数和工具类。
+
+提供统一的测试辅助工具,减少测试代码重复。
+"""
+
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock
+
+from astrbot.core.message.components import BaseMessageComponent
+
+
+class NoopAwaitable:
+ """可等待的空操作对象。
+
+ 用于 mock 需要返回 awaitable 对象的方法。
+ """
+
+ def __await__(self):
+ if False:
+ yield
+ return None
+
+
+# ============================================================
+# 平台配置工厂
+# ============================================================
+
+
+def make_platform_config(platform_type: str, **kwargs) -> dict:
+ """平台配置工厂函数。
+
+ Args:
+ platform_type: 平台类型 (telegram, discord, aiocqhttp 等)
+ **kwargs: 覆盖默认配置的字段
+
+ Returns:
+ dict: 平台配置字典
+ """
+ configs = {
+ "telegram": {
+ "id": "test_telegram",
+ "telegram_token": "test_token_123",
+ "telegram_api_base_url": "https://api.telegram.org/bot",
+ "telegram_file_base_url": "https://api.telegram.org/file/bot",
+ "telegram_command_register": True,
+ "telegram_command_auto_refresh": True,
+ "telegram_command_register_interval": 300,
+ "telegram_media_group_timeout": 2.5,
+ "telegram_media_group_max_wait": 10.0,
+ "start_message": "Welcome to AstrBot!",
+ },
+ "discord": {
+ "id": "test_discord",
+ "discord_token": "test_token_123",
+ "discord_proxy": None,
+ "discord_command_register": True,
+ "discord_guild_id_for_debug": None,
+ "discord_activity_name": "Playing AstrBot",
+ },
+ "aiocqhttp": {
+ "id": "test_aiocqhttp",
+ "ws_reverse_host": "0.0.0.0",
+ "ws_reverse_port": 6199,
+ "ws_reverse_token": "test_token",
+ },
+ "webchat": {
+ "id": "test_webchat",
+ },
+ "wecom": {
+ "id": "test_wecom",
+ "wecom_corpid": "test_corpid",
+ "wecom_secret": "test_secret",
+ },
+ }
+ config = configs.get(platform_type, {"id": f"test_{platform_type}"}).copy()
+ config.update(kwargs)
+ return config
+
+
+# ============================================================
+# Telegram 辅助函数
+# ============================================================
+
+
+def create_mock_update(
+ message_text: str | None = "Hello World",
+ chat_type: str = "private",
+ chat_id: int = 123456789,
+ user_id: int = 987654321,
+ username: str = "test_user",
+ message_id: int = 1,
+ media_group_id: str | None = None,
+ photo: list | None = None,
+ video: MagicMock | None = None,
+ document: MagicMock | None = None,
+ voice: MagicMock | None = None,
+ sticker: MagicMock | None = None,
+ reply_to_message: MagicMock | None = None,
+ caption: str | None = None,
+ entities: list | None = None,
+ caption_entities: list | None = None,
+ message_thread_id: int | None = None,
+ is_topic_message: bool = False,
+):
+ """创建模拟的 Telegram Update 对象。
+
+ Args:
+ message_text: 消息文本
+ chat_type: 聊天类型
+ chat_id: 聊天 ID
+ user_id: 用户 ID
+ username: 用户名
+ message_id: 消息 ID
+ media_group_id: 媒体组 ID
+ photo: 图片列表
+ video: 视频对象
+ document: 文档对象
+ voice: 语音对象
+ sticker: 贴纸对象
+ reply_to_message: 回复的消息
+ caption: 说明文字
+ entities: 实体列表
+ caption_entities: 说明实体列表
+ message_thread_id: 消息线程 ID
+ is_topic_message: 是否为主题消息
+
+ Returns:
+ MagicMock: 模拟的 Update 对象
+ """
+ update = MagicMock()
+ update.update_id = 1
+
+ # Create message mock
+ message = MagicMock()
+ message.message_id = message_id
+ message.chat = MagicMock()
+ message.chat.id = chat_id
+ message.chat.type = chat_type
+ message.message_thread_id = message_thread_id
+ message.is_topic_message = is_topic_message
+
+ # Create user mock
+ from_user = MagicMock()
+ from_user.id = user_id
+ from_user.username = username
+ message.from_user = from_user
+
+ # Set message content
+ message.text = message_text
+ message.media_group_id = media_group_id
+ message.photo = photo
+ message.video = video
+ message.document = document
+ message.voice = voice
+ message.sticker = sticker
+ message.reply_to_message = reply_to_message
+ message.caption = caption
+ message.entities = entities
+ message.caption_entities = caption_entities
+
+ update.message = message
+ update.effective_chat = message.chat
+
+ return update
+
+
+def create_mock_file(file_path: str = "https://api.telegram.org/file/test.jpg"):
+ """创建模拟的 Telegram File 对象。
+
+ Args:
+ file_path: 文件路径
+
+ Returns:
+ MagicMock: 模拟的 File 对象
+ """
+ file = MagicMock()
+ file.file_path = file_path
+ file.get_file = AsyncMock(return_value=file)
+ return file
+
+
+# ============================================================
+# Discord 辅助函数
+# ============================================================
+
+
+def create_mock_discord_attachment(
+ filename: str = "test.txt",
+ url: str = "https://cdn.discordapp.com/test.txt",
+ content_type: str | None = None,
+ size: int = 1024,
+):
+ """创建模拟的 Discord Attachment 对象。
+
+ Args:
+ filename: 文件名
+ url: 文件 URL
+ content_type: 内容类型
+ size: 文件大小
+
+ Returns:
+ MagicMock: 模拟的 Attachment 对象
+ """
+ attachment = MagicMock()
+ attachment.filename = filename
+ attachment.url = url
+ attachment.content_type = content_type
+ attachment.size = size
+ return attachment
+
+
+def create_mock_discord_user(
+ user_id: int = 123456789,
+ name: str = "TestUser",
+ display_name: str = "Test User",
+ bot: bool = False,
+):
+ """创建模拟的 Discord User 对象。
+
+ Args:
+ user_id: 用户 ID
+ name: 用户名
+ display_name: 显示名
+ bot: 是否为机器人
+
+ Returns:
+ MagicMock: 模拟的 User 对象
+ """
+ user = MagicMock()
+ user.id = user_id
+ user.name = name
+ user.display_name = display_name
+ user.bot = bot
+ user.mention = f"<@{user_id}>"
+ return user
+
+
+def create_mock_discord_channel(
+ channel_id: int = 111222333,
+ channel_type: str = "text",
+ name: str = "general",
+ guild_id: int | None = 444555666,
+):
+ """创建模拟的 Discord Channel 对象。
+
+ Args:
+ channel_id: 频道 ID
+ channel_type: 频道类型
+ name: 频道名
+ guild_id: 服务器 ID
+
+ Returns:
+ MagicMock: 模拟的 Channel 对象
+ """
+ channel = MagicMock()
+ channel.id = channel_id
+ channel.name = name
+ channel.type = channel_type
+
+ if guild_id:
+ channel.guild = MagicMock()
+ channel.guild.id = guild_id
+ else:
+ channel.guild = None
+
+ return channel
+
+
+# ============================================================
+# 消息组件辅助函数
+# ============================================================
+
+
+def create_mock_message_component(
+ component_type: str,
+ **kwargs: Any,
+) -> BaseMessageComponent:
+ """创建模拟的消息组件。
+
+ Args:
+ component_type: 组件类型 (plain, image, at, reply, file)
+ **kwargs: 组件参数
+
+ Returns:
+ BaseMessageComponent: 消息组件实例
+ """
+ from astrbot.core.message import components as Comp
+
+ component_map = {
+ "plain": Comp.Plain,
+ "image": Comp.Image,
+ "at": Comp.At,
+ "reply": Comp.Reply,
+ "file": Comp.File,
+ }
+
+ component_class = component_map.get(component_type.lower())
+ if not component_class:
+ raise ValueError(f"Unknown component type: {component_type}")
+
+ return component_class(**kwargs)
+
+
+def create_mock_llm_response(
+ completion_text: str = "Hello! How can I help you?",
+ role: str = "assistant",
+ tools_call_name: list[str] | None = None,
+ tools_call_args: list[dict] | None = None,
+ tools_call_ids: list[str] | None = None,
+):
+ """创建模拟的 LLM 响应。
+
+ Args:
+ completion_text: 完成文本
+ role: 角色
+ tools_call_name: 工具调用名称列表
+ tools_call_args: 工具调用参数列表
+ tools_call_ids: 工具调用 ID 列表
+
+ Returns:
+ LLMResponse: 模拟的 LLM 响应
+ """
+ from astrbot.core.provider.entities import LLMResponse, TokenUsage
+
+ return LLMResponse(
+ role=role,
+ completion_text=completion_text,
+ tools_call_name=tools_call_name or [],
+ tools_call_args=tools_call_args or [],
+ tools_call_ids=tools_call_ids or [],
+ usage=TokenUsage(input_other=10, output=5),
+ )
diff --git a/tests/fixtures/messages/test_messages.json b/tests/fixtures/messages/test_messages.json
new file mode 100644
index 000000000..0a3a7073f
--- /dev/null
+++ b/tests/fixtures/messages/test_messages.json
@@ -0,0 +1,33 @@
+{
+ "plain_message": {
+ "type": "plain",
+ "text": "Hello, this is a test message."
+ },
+ "image_message": {
+ "type": "image",
+ "url": "https://example.com/test.jpg",
+ "file": null
+ },
+ "at_message": {
+ "type": "at",
+ "user_id": "12345",
+ "nickname": "TestUser"
+ },
+ "reply_message": {
+ "type": "reply",
+ "id": "msg_123",
+ "sender_nickname": "OriginalSender",
+ "message_str": "This is the original message"
+ },
+ "file_message": {
+ "type": "file",
+ "name": "test.pdf",
+ "url": "https://example.com/test.pdf"
+ },
+ "combined_message": {
+ "components": [
+ {"type": "at", "user_id": "bot_id"},
+ {"type": "plain", "text": " Hello bot!"}
+ ]
+ }
+}
diff --git a/tests/fixtures/mocks/__init__.py b/tests/fixtures/mocks/__init__.py
new file mode 100644
index 000000000..c6497f1f2
--- /dev/null
+++ b/tests/fixtures/mocks/__init__.py
@@ -0,0 +1,43 @@
+"""测试 Mock 模块。
+
+提供统一的 mock 工具和 fixture,减少测试代码重复。
+
+使用方式:
+ # 在测试文件顶部导入需要的 fixture
+ from tests.fixtures.mocks import mock_telegram_modules
+
+ # 或使用 Builder 类创建 mock 对象
+ from tests.fixtures.mocks import MockTelegramBuilder
+ bot = MockTelegramBuilder.create_bot()
+"""
+
+from .aiocqhttp import (
+ MockAiocqhttpBuilder,
+ create_mock_aiocqhttp_modules,
+ mock_aiocqhttp_modules,
+)
+from .discord import (
+ MockDiscordBuilder,
+ create_mock_discord_modules,
+ mock_discord_modules,
+)
+from .telegram import (
+ MockTelegramBuilder,
+ create_mock_telegram_modules,
+ mock_telegram_modules,
+)
+
+__all__ = [
+ # Telegram
+ "mock_telegram_modules",
+ "create_mock_telegram_modules",
+ "MockTelegramBuilder",
+ # Discord
+ "mock_discord_modules",
+ "create_mock_discord_modules",
+ "MockDiscordBuilder",
+ # Aiocqhttp
+ "mock_aiocqhttp_modules",
+ "create_mock_aiocqhttp_modules",
+ "MockAiocqhttpBuilder",
+]
diff --git a/tests/fixtures/mocks/aiocqhttp.py b/tests/fixtures/mocks/aiocqhttp.py
new file mode 100644
index 000000000..d5e3c8229
--- /dev/null
+++ b/tests/fixtures/mocks/aiocqhttp.py
@@ -0,0 +1,58 @@
+"""Aiocqhttp 模块 Mock 工具。
+
+提供统一的 aiocqhttp 相关模块 mock 设置,避免在测试文件中重复定义。
+"""
+
+import sys
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+
+def create_mock_aiocqhttp_modules():
+ """创建 aiocqhttp 相关的 mock 模块。
+
+ Returns:
+ dict: 包含 aiocqhttp 和相关模块的 mock 对象
+ """
+ mock_aiocqhttp = MagicMock()
+ mock_aiocqhttp.CQHttp = MagicMock
+ mock_aiocqhttp.Event = MagicMock
+ mock_aiocqhttp.exceptions = MagicMock()
+ mock_aiocqhttp.exceptions.ActionFailed = Exception
+
+ return mock_aiocqhttp
+
+
+@pytest.fixture(scope="module", autouse=True)
+def mock_aiocqhttp_modules():
+ """Mock aiocqhttp 相关模块的 fixture。
+
+ 自动应用于使用此 fixture 的测试模块。
+ """
+ mock_aiocqhttp = create_mock_aiocqhttp_modules()
+ monkeypatch = pytest.MonkeyPatch()
+
+ monkeypatch.setitem(sys.modules, "aiocqhttp", mock_aiocqhttp)
+ monkeypatch.setitem(sys.modules, "aiocqhttp.exceptions", mock_aiocqhttp.exceptions)
+ yield
+ monkeypatch.undo()
+
+
+class MockAiocqhttpBuilder:
+ """构建 aiocqhttp 测试 mock 对象的工具类。"""
+
+ @staticmethod
+ def create_bot():
+ """创建 mock CQHttp bot 实例。"""
+ from tests.fixtures.helpers import NoopAwaitable
+
+ bot = MagicMock()
+ bot.send = AsyncMock()
+ bot.call_action = AsyncMock()
+ bot.on_request = MagicMock()
+ bot.on_notice = MagicMock()
+ bot.on_message = MagicMock()
+ bot.on_websocket_connection = MagicMock()
+ bot.run_task = MagicMock(return_value=NoopAwaitable())
+ return bot
diff --git a/tests/fixtures/mocks/discord.py b/tests/fixtures/mocks/discord.py
new file mode 100644
index 000000000..e13786af1
--- /dev/null
+++ b/tests/fixtures/mocks/discord.py
@@ -0,0 +1,140 @@
+"""Discord 模块 Mock 工具。
+
+提供统一的 Discord 相关模块 mock 设置,避免在测试文件中重复定义。
+"""
+
+import sys
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+
+def create_mock_discord_modules():
+ """创建 Discord 相关的 mock 模块。
+
+ Returns:
+ dict: 包含 discord 和相关模块的 mock 对象
+ """
+ mock_discord = MagicMock()
+
+ # Mock discord.Intents
+ mock_intents = MagicMock()
+ mock_intents.default = MagicMock(return_value=mock_intents)
+ mock_discord.Intents = mock_intents
+
+ # Mock discord.Status
+ mock_discord.Status = MagicMock()
+ mock_discord.Status.online = "online"
+
+ # Mock discord.Bot
+ mock_bot = MagicMock()
+ mock_discord.Bot = MagicMock(return_value=mock_bot)
+
+ # Mock discord.Embed
+ mock_embed = MagicMock()
+ mock_discord.Embed = MagicMock(return_value=mock_embed)
+
+ # Mock discord.ui
+ mock_ui = MagicMock()
+ mock_ui.View = MagicMock
+ mock_ui.Button = MagicMock
+ mock_discord.ui = mock_ui
+
+ # Mock discord.Message
+ mock_discord.Message = MagicMock
+
+ # Mock discord.Interaction
+ mock_discord.Interaction = MagicMock
+ mock_discord.InteractionType = MagicMock()
+ mock_discord.InteractionType.application_command = 2
+ mock_discord.InteractionType.component = 3
+
+ # Mock discord.File
+ mock_discord.File = MagicMock
+
+ # Mock discord.SlashCommand
+ mock_discord.SlashCommand = MagicMock
+
+ # Mock discord.Option
+ mock_discord.Option = MagicMock
+
+ # Mock discord.SlashCommandOptionType
+ mock_discord.SlashCommandOptionType = MagicMock()
+ mock_discord.SlashCommandOptionType.string = 3
+
+ # Mock discord.errors
+ mock_discord.errors = MagicMock()
+ mock_discord.errors.LoginFailure = Exception
+ mock_discord.errors.ConnectionClosed = Exception
+ mock_discord.errors.NotFound = Exception
+ mock_discord.errors.Forbidden = Exception
+
+ # Mock discord.abc
+ mock_discord.abc = MagicMock()
+ mock_discord.abc.GuildChannel = MagicMock
+ mock_discord.abc.Messageable = MagicMock
+ mock_discord.abc.PrivateChannel = MagicMock
+
+ # Mock discord.channel
+ mock_channel = MagicMock()
+ mock_channel.DMChannel = MagicMock
+ mock_discord.channel = mock_channel
+
+ # Mock discord.types
+ mock_discord.types = MagicMock()
+ mock_discord.types.interactions = MagicMock()
+
+ # Mock discord.ApplicationContext
+ mock_discord.ApplicationContext = MagicMock
+
+ # Mock discord.CustomActivity
+ mock_discord.CustomActivity = MagicMock
+
+ return mock_discord
+
+
+@pytest.fixture(scope="module", autouse=True)
+def mock_discord_modules():
+ """Mock Discord 相关模块的 fixture。
+
+ 自动应用于使用此 fixture 的测试模块。
+ """
+ mock_discord = create_mock_discord_modules()
+ monkeypatch = pytest.MonkeyPatch()
+
+ monkeypatch.setitem(sys.modules, "discord", mock_discord)
+ monkeypatch.setitem(sys.modules, "discord.abc", mock_discord.abc)
+ monkeypatch.setitem(sys.modules, "discord.channel", mock_discord.channel)
+ monkeypatch.setitem(sys.modules, "discord.errors", mock_discord.errors)
+ monkeypatch.setitem(sys.modules, "discord.types", mock_discord.types)
+ monkeypatch.setitem(
+ sys.modules,
+ "discord.types.interactions",
+ mock_discord.types.interactions,
+ )
+ monkeypatch.setitem(sys.modules, "discord.ui", mock_discord.ui)
+ yield
+ monkeypatch.undo()
+
+
+class MockDiscordBuilder:
+ """构建 Discord 测试 mock 对象的工具类。"""
+
+ @staticmethod
+ def create_client():
+ """创建 mock Discord client 实例。"""
+ client = MagicMock()
+ client.user = MagicMock()
+ client.user.id = 123456789
+ client.user.display_name = "TestBot"
+ client.user.name = "TestBot"
+ client.get_channel = MagicMock()
+ client.fetch_channel = AsyncMock()
+ client.get_message = MagicMock()
+ client.start = AsyncMock()
+ client.close = AsyncMock()
+ client.is_closed = MagicMock(return_value=False)
+ client.add_application_command = MagicMock()
+ client.sync_commands = AsyncMock()
+ client.change_presence = AsyncMock()
+ return client
diff --git a/tests/fixtures/mocks/telegram.py b/tests/fixtures/mocks/telegram.py
new file mode 100644
index 000000000..fbe4d0436
--- /dev/null
+++ b/tests/fixtures/mocks/telegram.py
@@ -0,0 +1,141 @@
+"""Telegram 模块 Mock 工具。
+
+提供统一的 Telegram 相关模块 mock 设置,避免在测试文件中重复定义。
+"""
+
+import sys
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+
+def create_mock_telegram_modules():
+ """创建 Telegram 相关的 mock 模块。
+
+ Returns:
+ dict: 包含 telegram 和相关模块的 mock 对象
+ """
+ mock_telegram = MagicMock()
+ mock_telegram.BotCommand = MagicMock
+ mock_telegram.Update = MagicMock
+ mock_telegram.constants = MagicMock()
+ mock_telegram.constants.ChatType = MagicMock()
+ mock_telegram.constants.ChatType.PRIVATE = "private"
+ mock_telegram.constants.ChatAction = MagicMock()
+ mock_telegram.constants.ChatAction.TYPING = "typing"
+ mock_telegram.constants.ChatAction.UPLOAD_VOICE = "upload_voice"
+ mock_telegram.constants.ChatAction.UPLOAD_DOCUMENT = "upload_document"
+ mock_telegram.constants.ChatAction.UPLOAD_PHOTO = "upload_photo"
+ mock_telegram.error = MagicMock()
+ mock_telegram.error.BadRequest = Exception
+ mock_telegram.ReactionTypeCustomEmoji = MagicMock
+ mock_telegram.ReactionTypeEmoji = MagicMock
+
+ mock_telegram_ext = MagicMock()
+ mock_telegram_ext.ApplicationBuilder = MagicMock
+ mock_telegram_ext.ContextTypes = MagicMock
+ mock_telegram_ext.ExtBot = MagicMock
+ mock_telegram_ext.filters = MagicMock()
+ mock_telegram_ext.filters.ALL = MagicMock()
+ mock_telegram_ext.MessageHandler = MagicMock
+
+ # Mock telegramify_markdown
+ mock_telegramify = MagicMock()
+ mock_telegramify.markdownify = lambda text, **kwargs: text
+
+ # Mock apscheduler
+ mock_apscheduler = MagicMock()
+ mock_apscheduler.schedulers = MagicMock()
+ mock_apscheduler.schedulers.asyncio = MagicMock()
+ mock_apscheduler.schedulers.asyncio.AsyncIOScheduler = MagicMock
+ mock_apscheduler.schedulers.background = MagicMock()
+ mock_apscheduler.schedulers.background.BackgroundScheduler = MagicMock
+
+ return {
+ "telegram": mock_telegram,
+ "telegram.ext": mock_telegram_ext,
+ "telegramify_markdown": mock_telegramify,
+ "apscheduler": mock_apscheduler,
+ }
+
+
+@pytest.fixture(scope="module", autouse=True)
+def mock_telegram_modules():
+ """Mock Telegram 相关模块的 fixture。
+
+ 自动应用于使用此 fixture 的测试模块。
+ """
+ mocks = create_mock_telegram_modules()
+ monkeypatch = pytest.MonkeyPatch()
+
+ monkeypatch.setitem(sys.modules, "telegram", mocks["telegram"])
+ monkeypatch.setitem(sys.modules, "telegram.constants", mocks["telegram"].constants)
+ monkeypatch.setitem(sys.modules, "telegram.error", mocks["telegram"].error)
+ monkeypatch.setitem(sys.modules, "telegram.ext", mocks["telegram.ext"])
+ monkeypatch.setitem(sys.modules, "telegramify_markdown", mocks["telegramify_markdown"])
+ monkeypatch.setitem(sys.modules, "apscheduler", mocks["apscheduler"])
+ monkeypatch.setitem(
+ sys.modules, "apscheduler.schedulers", mocks["apscheduler"].schedulers
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "apscheduler.schedulers.asyncio",
+ mocks["apscheduler"].schedulers.asyncio,
+ )
+ monkeypatch.setitem(
+ sys.modules,
+ "apscheduler.schedulers.background",
+ mocks["apscheduler"].schedulers.background,
+ )
+ yield
+ monkeypatch.undo()
+
+
+class MockTelegramBuilder:
+ """构建 Telegram 测试 mock 对象的工具类。"""
+
+ @staticmethod
+ def create_bot():
+ """创建 mock Telegram bot 实例。"""
+ bot = MagicMock()
+ bot.username = "test_bot"
+ bot.id = 12345678
+ bot.base_url = "https://api.telegram.org/bottest_token_123/"
+ bot.send_message = AsyncMock()
+ bot.send_photo = AsyncMock()
+ bot.send_document = AsyncMock()
+ bot.send_voice = AsyncMock()
+ bot.send_chat_action = AsyncMock()
+ bot.delete_my_commands = AsyncMock()
+ bot.set_my_commands = AsyncMock()
+ bot.set_message_reaction = AsyncMock()
+ bot.edit_message_text = AsyncMock()
+ return bot
+
+ @staticmethod
+ def create_application():
+ """创建 mock Telegram Application 实例。"""
+ from tests.fixtures.helpers import NoopAwaitable
+
+ app = MagicMock()
+ app.bot = MagicMock()
+ app.bot.username = "test_bot"
+ app.bot.base_url = "https://api.telegram.org/bottest_token_123/"
+ app.initialize = AsyncMock()
+ app.start = AsyncMock()
+ app.stop = AsyncMock()
+ app.add_handler = MagicMock()
+ app.updater = MagicMock()
+ app.updater.start_polling = MagicMock(return_value=NoopAwaitable())
+ app.updater.stop = AsyncMock()
+ return app
+
+ @staticmethod
+ def create_scheduler():
+ """创建 mock APScheduler 实例。"""
+ scheduler = MagicMock()
+ scheduler.add_job = MagicMock()
+ scheduler.start = MagicMock()
+ scheduler.running = True
+ scheduler.shutdown = MagicMock()
+ return scheduler
diff --git a/tests/fixtures/plugins/fixture_plugin.py b/tests/fixtures/plugins/fixture_plugin.py
new file mode 100644
index 000000000..455b5b759
--- /dev/null
+++ b/tests/fixtures/plugins/fixture_plugin.py
@@ -0,0 +1,40 @@
+"""
+测试插件 - 用于插件系统测试
+
+这是一个最小化的测试插件,用于验证插件系统的功能。
+"""
+
+from astrbot.api import llm_tool, star
+from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
+
+
+@star.register("test_plugin", "AstrBot Team", "测试插件 - 用于插件系统测试", "1.0.0")
+class TestPlugin(star.Star):
+ """测试插件类"""
+
+ def __init__(self, context: star.Context) -> None:
+ super().__init__(context)
+ self.initialized = True
+
+ async def terminate(self) -> None:
+ """插件终止"""
+ self.initialized = False
+
+ @filter.command("test_cmd")
+ async def test_command(self, event: AstrMessageEvent) -> None:
+ """测试命令处理器。"""
+ event.set_result(MessageEventResult().message("测试命令执行成功"))
+
+ @llm_tool("test_tool")
+ async def test_llm_tool(self, query: str) -> str:
+ """测试 LLM 工具。
+
+ Args:
+ query(string): 查询内容。
+ """
+ return f"测试工具执行成功: {query}"
+
+ @filter.regex(r"^test_regex_(.+)$")
+ async def test_regex_handler(self, event: AstrMessageEvent) -> None:
+ """测试正则处理器。"""
+ event.set_result(MessageEventResult().message("正则匹配成功"))
diff --git a/tests/fixtures/plugins/metadata.yaml b/tests/fixtures/plugins/metadata.yaml
new file mode 100644
index 000000000..2554fb15d
--- /dev/null
+++ b/tests/fixtures/plugins/metadata.yaml
@@ -0,0 +1,5 @@
+name: test_plugin
+description: 测试插件 - 用于插件系统测试
+version: 1.0.0
+author: AstrBot Team
+repo: https://github.com/test/test_plugin
diff --git a/tests/test_api_key_open_api.py b/tests/test_api_key_open_api.py
new file mode 100644
index 000000000..3d1ea0a0f
--- /dev/null
+++ b/tests/test_api_key_open_api.py
@@ -0,0 +1,334 @@
+import asyncio
+import uuid
+
+import pytest
+import pytest_asyncio
+from quart import Quart, g, request
+
+from astrbot.core import LogBroker
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.db.sqlite import SQLiteDatabase
+from astrbot.dashboard.routes.route import Response
+from astrbot.dashboard.server import AstrBotDashboard
+
+
+@pytest_asyncio.fixture(scope="module")
+async def core_lifecycle_td(tmp_path_factory):
+ tmp_db_path = tmp_path_factory.mktemp("data") / "test_data_api_key.db"
+ db = SQLiteDatabase(str(tmp_db_path))
+ log_broker = LogBroker()
+ core_lifecycle = AstrBotCoreLifecycle(log_broker, db)
+ await core_lifecycle.initialize()
+ try:
+ yield core_lifecycle
+ finally:
+ try:
+ stop_result = core_lifecycle.stop()
+ if asyncio.iscoroutine(stop_result):
+ await stop_result
+ except Exception:
+ pass
+
+
+@pytest.fixture(scope="module")
+def app(core_lifecycle_td: AstrBotCoreLifecycle):
+ shutdown_event = asyncio.Event()
+ server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event)
+ return server.app
+
+
+@pytest_asyncio.fixture(scope="module")
+async def authenticated_header(app: Quart, core_lifecycle_td: AstrBotCoreLifecycle):
+ test_client = app.test_client()
+ response = await test_client.post(
+ "/api/auth/login",
+ json={
+ "username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
+ "password": core_lifecycle_td.astrbot_config["dashboard"]["password"],
+ },
+ )
+ data = await response.get_json()
+ token = data["data"]["token"]
+ return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.mark.asyncio
+async def test_api_key_scope_and_revoke(app: Quart, authenticated_header: dict):
+ test_client = app.test_client()
+
+ create_res = await test_client.post(
+ "/api/apikey/create",
+ json={"name": "im-scope-key", "scopes": ["im"]},
+ headers=authenticated_header,
+ )
+ assert create_res.status_code == 200
+ create_data = await create_res.get_json()
+ assert create_data["status"] == "ok"
+ raw_key = create_data["data"]["api_key"]
+ key_id = create_data["data"]["key_id"]
+
+ open_bot_res = await test_client.get(
+ "/api/v1/im/bots",
+ headers={"X-API-Key": raw_key},
+ )
+ assert open_bot_res.status_code == 200
+ open_bot_data = await open_bot_res.get_json()
+ assert open_bot_data["status"] == "ok"
+ assert isinstance(open_bot_data["data"]["bot_ids"], list)
+
+ denied_chat_sessions_res = await test_client.get(
+ "/api/v1/chat/sessions?page=1&page_size=10",
+ headers={"X-API-Key": raw_key},
+ )
+ assert denied_chat_sessions_res.status_code == 403
+
+ denied_chat_configs_res = await test_client.get(
+ "/api/v1/configs",
+ headers={"X-API-Key": raw_key},
+ )
+ assert denied_chat_configs_res.status_code == 403
+
+ denied_res = await test_client.post(
+ "/api/v1/file",
+ data={},
+ headers={"X-API-Key": raw_key},
+ )
+ assert denied_res.status_code == 403
+
+ revoke_res = await test_client.post(
+ "/api/apikey/revoke",
+ json={"key_id": key_id},
+ headers=authenticated_header,
+ )
+ assert revoke_res.status_code == 200
+ revoke_data = await revoke_res.get_json()
+ assert revoke_data["status"] == "ok"
+
+ revoked_access_res = await test_client.get(
+ "/api/v1/im/bots",
+ headers={"X-API-Key": raw_key},
+ )
+ assert revoked_access_res.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_open_send_message_with_api_key(app: Quart, authenticated_header: dict):
+ test_client = app.test_client()
+
+ create_res = await test_client.post(
+ "/api/apikey/create",
+ json={"name": "send-message-key", "scopes": ["im"]},
+ headers=authenticated_header,
+ )
+ create_data = await create_res.get_json()
+ assert create_data["status"] == "ok"
+ raw_key = create_data["data"]["api_key"]
+
+ send_res = await test_client.post(
+ "/api/v1/im/message",
+ json={
+ "umo": "webchat:FriendMessage:open_api_test_session",
+ "message": "hello",
+ },
+ headers={"X-API-Key": raw_key},
+ )
+ assert send_res.status_code == 200
+ send_data = await send_res.get_json()
+ assert send_data["status"] == "ok"
+
+
+@pytest.mark.asyncio
+async def test_open_chat_send_auto_session_id_and_username(
+ app: Quart,
+ authenticated_header: dict,
+ core_lifecycle_td: AstrBotCoreLifecycle,
+):
+ test_client = app.test_client()
+
+ create_res = await test_client.post(
+ "/api/apikey/create",
+ json={"name": "chat-send-key", "scopes": ["chat"]},
+ headers=authenticated_header,
+ )
+ create_data = await create_res.get_json()
+ assert create_data["status"] == "ok"
+ raw_key = create_data["data"]["api_key"]
+
+ rule = next(
+ (
+ item
+ for item in app.url_map.iter_rules()
+ if item.rule == "/api/v1/chat" and "POST" in item.methods
+ ),
+ None,
+ )
+ assert rule is not None
+ open_api_route = app.view_functions[rule.endpoint].__self__
+
+ original_chat = open_api_route.chat_route.chat
+
+ async def fake_chat(post_data: dict | None = None):
+ payload = post_data or await request.get_json()
+ return (
+ Response()
+ .ok(
+ data={
+ "session_id": payload.get("session_id"),
+ "creator": g.get("username"),
+ }
+ )
+ .__dict__
+ )
+
+ open_api_route.chat_route.chat = fake_chat
+ try:
+ send_res = await test_client.post(
+ "/api/v1/chat",
+ json={
+ "message": "hello",
+ "username": "alice",
+ "enable_streaming": False,
+ },
+ headers={"X-API-Key": raw_key},
+ )
+ finally:
+ open_api_route.chat_route.chat = original_chat
+
+ assert send_res.status_code == 200
+ send_data = await send_res.get_json()
+ assert send_data["status"] == "ok"
+ created_session_id = send_data["data"]["session_id"]
+ assert isinstance(created_session_id, str)
+ uuid.UUID(created_session_id)
+ assert send_data["data"]["creator"] == "alice"
+ created_session = await core_lifecycle_td.db.get_platform_session_by_id(
+ created_session_id
+ )
+ assert created_session is not None
+ assert created_session.creator == "alice"
+ assert created_session.platform_id == "webchat"
+
+ await core_lifecycle_td.db.create_platform_session(
+ creator="bob",
+ platform_id="webchat",
+ session_id="open_api_existing_bob_session",
+ is_group=0,
+ )
+ another_user_session_res = await test_client.post(
+ "/api/v1/chat",
+ json={
+ "message": "hello",
+ "username": "alice",
+ "session_id": "open_api_existing_bob_session",
+ "enable_streaming": False,
+ },
+ headers={"X-API-Key": raw_key},
+ )
+ another_user_session_data = await another_user_session_res.get_json()
+ assert another_user_session_data["status"] == "error"
+ assert (
+ another_user_session_data["message"]
+ == "session_id belongs to another username"
+ )
+
+ missing_username_res = await test_client.post(
+ "/api/v1/chat",
+ json={"message": "hello"},
+ headers={"X-API-Key": raw_key},
+ )
+ missing_username_data = await missing_username_res.get_json()
+ assert missing_username_data["status"] == "error"
+ assert missing_username_data["message"] == "Missing key: username"
+
+
+@pytest.mark.asyncio
+async def test_open_chat_sessions_pagination(
+ app: Quart,
+ authenticated_header: dict,
+ core_lifecycle_td: AstrBotCoreLifecycle,
+):
+ test_client = app.test_client()
+
+ create_res = await test_client.post(
+ "/api/apikey/create",
+ json={"name": "chat-scope-key", "scopes": ["chat"]},
+ headers=authenticated_header,
+ )
+ create_data = await create_res.get_json()
+ assert create_data["status"] == "ok"
+ raw_key = create_data["data"]["api_key"]
+
+ creator = "alice"
+ for idx in range(3):
+ await core_lifecycle_td.db.create_platform_session(
+ creator=creator,
+ platform_id="webchat",
+ session_id=f"open_api_paginated_{idx}",
+ display_name=f"Open API Session {idx}",
+ is_group=0,
+ )
+ await core_lifecycle_td.db.create_platform_session(
+ creator="bob",
+ platform_id="webchat",
+ session_id="open_api_paginated_bob",
+ display_name="Open API Session Bob",
+ is_group=0,
+ )
+
+ page_1_res = await test_client.get(
+ "/api/v1/chat/sessions?page=1&page_size=2&username=alice",
+ headers={"X-API-Key": raw_key},
+ )
+ assert page_1_res.status_code == 200
+ page_1_data = await page_1_res.get_json()
+ assert page_1_data["status"] == "ok"
+ assert page_1_data["data"]["page"] == 1
+ assert page_1_data["data"]["page_size"] == 2
+ assert page_1_data["data"]["total"] == 3
+ assert len(page_1_data["data"]["sessions"]) == 2
+ assert all(item["creator"] == "alice" for item in page_1_data["data"]["sessions"])
+
+ page_2_res = await test_client.get(
+ "/api/v1/chat/sessions?page=2&page_size=2&username=alice",
+ headers={"X-API-Key": raw_key},
+ )
+ assert page_2_res.status_code == 200
+ page_2_data = await page_2_res.get_json()
+ assert page_2_data["status"] == "ok"
+ assert page_2_data["data"]["page"] == 2
+ assert len(page_2_data["data"]["sessions"]) == 1
+
+ missing_username_res = await test_client.get(
+ "/api/v1/chat/sessions?page=1&page_size=2",
+ headers={"X-API-Key": raw_key},
+ )
+ missing_username_data = await missing_username_res.get_json()
+ assert missing_username_data["status"] == "error"
+ assert missing_username_data["message"] == "Missing key: username"
+
+
+@pytest.mark.asyncio
+async def test_open_chat_configs_list(
+ app: Quart,
+ authenticated_header: dict,
+):
+ test_client = app.test_client()
+
+ create_res = await test_client.post(
+ "/api/apikey/create",
+ json={"name": "chat-config-key", "scopes": ["config"]},
+ headers=authenticated_header,
+ )
+ create_data = await create_res.get_json()
+ assert create_data["status"] == "ok"
+ raw_key = create_data["data"]["api_key"]
+
+ configs_res = await test_client.get(
+ "/api/v1/configs",
+ headers={"X-API-Key": raw_key},
+ )
+ assert configs_res.status_code == 200
+ configs_data = await configs_res.get_json()
+ assert configs_data["status"] == "ok"
+ assert isinstance(configs_data["data"]["configs"], list)
+ assert any(item["id"] == "default" for item in configs_data["data"]["configs"])
diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py
new file mode 100644
index 000000000..3172097c7
--- /dev/null
+++ b/tests/test_openai_source.py
@@ -0,0 +1,382 @@
+from types import SimpleNamespace
+
+import pytest
+
+from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
+
+
+class _ErrorWithBody(Exception):
+ def __init__(self, message: str, body: dict):
+ super().__init__(message)
+ self.body = body
+
+
+class _ErrorWithResponse(Exception):
+ def __init__(self, message: str, response_text: str):
+ super().__init__(message)
+ self.response = SimpleNamespace(text=response_text)
+
+
+def _make_provider(overrides: dict | None = None) -> ProviderOpenAIOfficial:
+ provider_config = {
+ "id": "test-openai",
+ "type": "openai_chat_completion",
+ "model": "gpt-4o-mini",
+ "key": ["test-key"],
+ }
+ if overrides:
+ provider_config.update(overrides)
+ return ProviderOpenAIOfficial(
+ provider_config=provider_config,
+ provider_settings={},
+ )
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_content_moderated_removes_images():
+ provider = _make_provider(
+ {"image_moderation_error_patterns": ["file:content-moderated"]}
+ )
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+
+ success, *_rest = await provider._handle_api_error(
+ Exception("Content is moderated [WKE=file:content-moderated]"),
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+
+ assert success is False
+ updated_context = payloads["messages"]
+ assert isinstance(updated_context, list)
+ assert updated_context[0]["content"] == [{"type": "text", "text": "hello"}]
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_model_not_vlm_removes_images_and_retries_text_only():
+ provider = _make_provider()
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+
+ success, *_rest = await provider._handle_api_error(
+ Exception("The model is not a VLM and cannot process images"),
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+
+ assert success is False
+ updated_context = payloads["messages"]
+ assert isinstance(updated_context, list)
+ assert updated_context[0]["content"] == [{"type": "text", "text": "hello"}]
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_model_not_vlm_after_fallback_raises():
+ provider = _make_provider()
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+
+ with pytest.raises(Exception, match="not a VLM"):
+ await provider._handle_api_error(
+ Exception("The model is not a VLM and cannot process images"),
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=1,
+ max_retries=10,
+ image_fallback_used=True,
+ )
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_content_moderated_with_unserializable_body():
+ provider = _make_provider({"image_moderation_error_patterns": ["blocked"]})
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+ err = _ErrorWithBody(
+ "upstream error",
+ {"error": {"message": "blocked"}, "raw": object()},
+ )
+
+ success, *_rest = await provider._handle_api_error(
+ err,
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+ assert success is False
+ assert payloads["messages"][0]["content"] == [{"type": "text", "text": "hello"}]
+ finally:
+ await provider.terminate()
+
+
+def test_extract_error_text_candidates_truncates_long_response_text():
+ long_text = "x" * 20000
+ err = _ErrorWithResponse("upstream error", long_text)
+ candidates = ProviderOpenAIOfficial._extract_error_text_candidates(err)
+ assert candidates
+ assert max(len(candidate) for candidate in candidates) <= (
+ ProviderOpenAIOfficial._ERROR_TEXT_CANDIDATE_MAX_CHARS
+ )
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_content_moderated_without_images_raises():
+ provider = _make_provider(
+ {"image_moderation_error_patterns": ["file:content-moderated"]}
+ )
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [{"type": "text", "text": "hello"}],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+ err = Exception("Content is moderated [WKE=file:content-moderated]")
+
+ with pytest.raises(Exception, match="content-moderated"):
+ await provider._handle_api_error(
+ err,
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_content_moderated_detects_structured_body():
+ provider = _make_provider(
+ {"image_moderation_error_patterns": ["content_moderated"]}
+ )
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+ err = _ErrorWithBody(
+ "upstream error",
+ {"error": {"code": "content_moderated", "message": "blocked"}},
+ )
+
+ success, *_rest = await provider._handle_api_error(
+ err,
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+ assert success is False
+ assert payloads["messages"][0]["content"] == [{"type": "text", "text": "hello"}]
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_content_moderated_supports_custom_patterns():
+ provider = _make_provider(
+ {"image_moderation_error_patterns": ["blocked_by_policy_code_123"]}
+ )
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+ err = Exception("upstream: blocked_by_policy_code_123")
+
+ success, *_rest = await provider._handle_api_error(
+ err,
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+ assert success is False
+ assert payloads["messages"][0]["content"] == [{"type": "text", "text": "hello"}]
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_content_moderated_without_patterns_raises():
+ provider = _make_provider()
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+ err = Exception("Content is moderated [WKE=file:content-moderated]")
+
+ with pytest.raises(Exception, match="content-moderated"):
+ await provider._handle_api_error(
+ err,
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+ finally:
+ await provider.terminate()
+
+
+@pytest.mark.asyncio
+async def test_handle_api_error_unknown_image_error_raises():
+ provider = _make_provider()
+ try:
+ payloads = {
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "hello"},
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/jpeg;base64,abcd"},
+ },
+ ],
+ }
+ ]
+ }
+ context_query = payloads["messages"]
+
+ with pytest.raises(Exception, match="unknown provider image upload error"):
+ await provider._handle_api_error(
+ Exception("some unknown provider image upload error"),
+ payloads=payloads,
+ context_query=context_query,
+ func_tool=None,
+ chosen_key="test-key",
+ available_api_keys=["test-key"],
+ retry_cnt=0,
+ max_retries=10,
+ )
+ finally:
+ await provider.terminate()
diff --git a/tests/test_quoted_message_parser.py b/tests/test_quoted_message_parser.py
new file mode 100644
index 000000000..0a0e126d5
--- /dev/null
+++ b/tests/test_quoted_message_parser.py
@@ -0,0 +1,494 @@
+from types import SimpleNamespace
+
+import pytest
+
+from astrbot.core.message.components import Image, Plain, Reply
+from astrbot.core.utils.quoted_message_parser import (
+ extract_quoted_message_images,
+ extract_quoted_message_text,
+)
+
+
+class _DummyAPI:
+ def __init__(
+ self,
+ responses: dict[tuple[str, str], dict],
+ param_responses: dict[tuple[str, tuple[tuple[str, str], ...]], dict]
+ | None = None,
+ ):
+ self._responses = responses
+ self._param_responses = param_responses or {}
+
+ async def call_action(self, action: str, **params):
+ param_key = (action, tuple(sorted((k, str(v)) for k, v in params.items())))
+ if param_key in self._param_responses:
+ return self._param_responses[param_key]
+
+ msg_id = params.get("message_id")
+ if msg_id is None:
+ msg_id = params.get("id")
+ key = (action, str(msg_id))
+ if key not in self._responses:
+ raise RuntimeError(f"no mock response for {key}")
+ return self._responses[key]
+
+
+class _FailIfCalledAPI:
+ async def call_action(self, action: str, **params):
+ raise AssertionError(
+ f"call_action should not be called, got action={action}, params={params}"
+ )
+
+
+def _make_event(
+ reply: Reply,
+ responses: dict[tuple[str, str], dict] | None = None,
+ param_responses: dict[tuple[str, tuple[tuple[str, str], ...]], dict] | None = None,
+):
+ if responses is None:
+ responses = {}
+ if param_responses is None:
+ param_responses = {}
+ return SimpleNamespace(
+ message_obj=SimpleNamespace(message=[reply]),
+ bot=SimpleNamespace(api=_DummyAPI(responses, param_responses)),
+ get_group_id=lambda: "",
+ )
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_from_reply_chain():
+ reply = Reply(id="1", chain=[Plain(text="quoted content")], message_str="")
+ event = _make_event(reply)
+ text = await extract_quoted_message_text(event)
+ assert text == "quoted content"
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_no_reply_component():
+ event = SimpleNamespace(
+ message_obj=SimpleNamespace(message=[Plain(text="unquoted message")]),
+ bot=SimpleNamespace(api=_DummyAPI({}, {})),
+ get_group_id=lambda: "",
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert text is None
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_no_reply_component():
+ event = SimpleNamespace(
+ message_obj=SimpleNamespace(message=[Plain(text="unquoted message")]),
+ bot=SimpleNamespace(api=_FailIfCalledAPI()),
+ get_group_id=lambda: "",
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("reply_id", [None, ""])
+async def test_extract_quoted_message_text_reply_without_id_does_not_call_get_msg(
+ reply_id: str | None,
+):
+ reply = Reply(
+ id="placeholder", chain=[Plain(text="quoted content")], message_str=""
+ )
+ object.__setattr__(reply, "id", reply_id)
+ event = SimpleNamespace(
+ message_obj=SimpleNamespace(message=[reply]),
+ bot=SimpleNamespace(api=_FailIfCalledAPI()),
+ get_group_id=lambda: "",
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert text == "quoted content"
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_fallback_get_msg_and_forward():
+ reply = Reply(id="100", chain=None, message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ (
+ "get_msg",
+ "100",
+ ): {
+ "data": {
+ "message": [
+ {"type": "text", "data": {"text": "parent"}},
+ {"type": "forward", "data": {"id": "fwd_1"}},
+ ]
+ }
+ },
+ (
+ "get_forward_msg",
+ "fwd_1",
+ ): {
+ "data": {
+ "messages": [
+ {
+ "sender": {"nickname": "Alice"},
+ "message": [{"type": "text", "data": {"text": "hello"}}],
+ },
+ {
+ "sender": {"nickname": "Bob"},
+ "message": [
+ {"type": "image", "data": {"url": "http://img"}},
+ {"type": "text", "data": {"text": "world"}},
+ ],
+ },
+ ]
+ }
+ },
+ },
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert text is not None
+ assert "parent" in text
+ assert "Alice: hello" in text
+ assert "Bob: [Image]world" in text
+
+
+@pytest.mark.parametrize(
+ "placeholder_text",
+ [
+ "[Forward Message]",
+ "[转发消息]",
+ "[合并转发]",
+ "Alice: [Forward Message]",
+ "(Alice): [转发消息]",
+ "[Forward Message]\n[转发消息]",
+ "Alice: [Forward Message]\n(Bob): [合并转发]",
+ "[转发消息]\n\n[合并转发]",
+ ],
+)
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_forward_placeholder_variants_trigger_fallback(
+ placeholder_text: str,
+):
+ reply = Reply(id="400", chain=[Plain(text=placeholder_text)], message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "400"): {
+ "data": {
+ "message": [
+ {"type": "text", "data": {"text": "Bob: "}},
+ {"type": "image", "data": {}},
+ {"type": "text", "data": {"text": "world"}},
+ ]
+ }
+ }
+ },
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert "Bob: [Image]world" in text
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_mixed_placeholder_does_not_trigger_fallback():
+ reply = Reply(
+ id="402",
+ chain=[Plain(text="Alice: [Forward Message]\nreal text")],
+ message_str="",
+ )
+ event = SimpleNamespace(
+ message_obj=SimpleNamespace(message=[reply]),
+ bot=SimpleNamespace(api=_FailIfCalledAPI()),
+ get_group_id=lambda: "",
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert text is not None
+ assert "[Forward Message]" in text
+ assert "real text" in text
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_forward_placeholder_fallback_failure():
+ reply = Reply(id="401", chain=[Plain(text="[Forward Message]")], message_str="")
+ event = _make_event(reply, responses={})
+
+ text = await extract_quoted_message_text(event)
+ assert text == "[Forward Message]"
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_text_multimsg_malformed_config_does_not_raise():
+ reply = Reply(id="402", chain=None, message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "402"): {
+ "data": {
+ "message": [
+ {
+ "type": "json",
+ "data": {
+ "data": (
+ '{"app":"com.tencent.multimsg",'
+ '"config":"oops","meta":{}}'
+ )
+ },
+ },
+ {"type": "text", "data": {"text": "still works"}},
+ ]
+ }
+ }
+ },
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert text == "still works"
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_from_reply_chain():
+ reply = Reply(
+ id="1",
+ chain=[
+ Plain(text="quoted"),
+ Image(file="https://img.example.com/a.jpg"),
+ ],
+ message_str="",
+ )
+ event = _make_event(reply)
+
+ images = await extract_quoted_message_images(event)
+ assert images == ["https://img.example.com/a.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_fallback_get_msg_direct_url():
+ reply = Reply(id="200", chain=None, message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "200"): {
+ "data": {
+ "message": [
+ {
+ "type": "image",
+ "data": {"url": "https://img.example.com/direct.jpg"},
+ }
+ ]
+ }
+ }
+ },
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == ["https://img.example.com/direct.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_data_image_ref_normalized_to_base64():
+ data_image_ref = "data:image/png;base64,abcd1234=="
+ reply = Reply(id="201", chain=None, message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "201"): {
+ "data": {
+ "message": [
+ {"type": "image", "data": {"url": data_image_ref}},
+ ]
+ }
+ }
+ },
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == ["base64://abcd1234=="]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_file_url_with_query_string():
+ url_with_query = "https://img.example.com/direct.jpg?token=abc123#frag"
+ reply = Reply(id="205", chain=None, message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "205"): {
+ "data": {
+ "message": [
+ {
+ "type": "file",
+ "data": {
+ "url": url_with_query,
+ "name": "direct.jpg",
+ },
+ }
+ ]
+ }
+ }
+ },
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == [url_with_query]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_non_image_local_path_is_ignored(tmp_path):
+ non_image_file = tmp_path / "secret.txt"
+ non_image_file.write_text("not an image", encoding="utf-8")
+
+ reply = Reply(
+ id="placeholder", chain=[Image(file=str(non_image_file))], message_str=""
+ )
+ object.__setattr__(reply, "id", None)
+ event = SimpleNamespace(
+ message_obj=SimpleNamespace(message=[reply]),
+ bot=SimpleNamespace(api=_FailIfCalledAPI()),
+ get_group_id=lambda: "",
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == []
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_chain_placeholder_triggers_fallback():
+ reply = Reply(id="210", chain=[Plain(text="[Forward Message]")], message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "210"): {
+ "data": {
+ "message": [
+ {
+ "type": "image",
+ "data": {
+ "url": "https://img.example.com/from-fallback.jpg"
+ },
+ }
+ ]
+ }
+ }
+ },
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == ["https://img.example.com/from-fallback.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_fallback_resolve_file_id_with_get_image():
+ reply = Reply(id="300", chain=None, message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "300"): {
+ "data": {"message": [{"type": "image", "data": {"file": "abc123.jpg"}}]}
+ }
+ },
+ param_responses={
+ ("get_image", (("file", "abc123.jpg"),)): {
+ "data": {"url": "https://img.example.com/resolved.jpg"}
+ }
+ },
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == ["https://img.example.com/resolved.jpg"]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_images_deduplicates_across_sources():
+ dup_url = "https://img.example.com/dup.jpg"
+ chain_only_url = "https://img.example.com/only-chain.jpg"
+ get_msg_only_url = "https://img.example.com/only-get-msg.jpg"
+ forward_only_url = "https://img.example.com/only-forward.jpg"
+
+ reply = Reply(
+ id="310",
+ chain=[Image(file=dup_url), Image(file=chain_only_url)],
+ message_str="",
+ )
+
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "310"): {
+ "data": {
+ "message": [
+ {"type": "image", "data": {"url": dup_url}},
+ {"type": "image", "data": {"url": get_msg_only_url}},
+ {"type": "forward", "data": {"id": "999"}},
+ ]
+ }
+ },
+ ("get_forward_msg", "999"): {
+ "data": {
+ "messages": [
+ {
+ "sender": {"nickname": "Tester"},
+ "message": [
+ {"type": "image", "data": {"url": dup_url}},
+ {"type": "image", "data": {"url": forward_only_url}},
+ ],
+ }
+ ]
+ }
+ },
+ },
+ )
+
+ images = await extract_quoted_message_images(event)
+ assert images == [
+ dup_url,
+ chain_only_url,
+ get_msg_only_url,
+ forward_only_url,
+ ]
+
+
+@pytest.mark.asyncio
+async def test_extract_quoted_message_nested_forward_id_is_resolved():
+ nested_image = "https://img.example.com/nested.jpg"
+ reply = Reply(id="320", chain=[Plain(text="[Forward Message]")], message_str="")
+ event = _make_event(
+ reply,
+ responses={
+ ("get_msg", "320"): {
+ "data": {"message": [{"type": "forward", "data": {"id": "fwd_1"}}]}
+ },
+ ("get_forward_msg", "fwd_1"): {
+ "data": {
+ "messages": [
+ {
+ "sender": {"nickname": "Alice"},
+ "message": [{"type": "forward", "data": {"id": "fwd_2"}}],
+ }
+ ]
+ }
+ },
+ ("get_forward_msg", "fwd_2"): {
+ "data": {
+ "messages": [
+ {
+ "sender": {"nickname": "Bob"},
+ "message": [
+ {"type": "text", "data": {"text": "deep"}},
+ {"type": "image", "data": {"url": nested_image}},
+ ],
+ }
+ ]
+ }
+ },
+ },
+ )
+
+ text = await extract_quoted_message_text(event)
+ assert text is not None
+ assert "Bob: deep" in text
+
+ images = await extract_quoted_message_images(event)
+ assert images == [nested_image]
diff --git a/tests/test_smoke.py b/tests/test_smoke.py
new file mode 100644
index 000000000..4474e1599
--- /dev/null
+++ b/tests/test_smoke.py
@@ -0,0 +1,115 @@
+"""Smoke tests for critical startup and import paths."""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+from astrbot.core.pipeline.bootstrap import ensure_builtin_stages_registered
+from astrbot.core.pipeline.process_stage.method.agent_sub_stages.internal import (
+ InternalAgentSubStage,
+)
+from astrbot.core.pipeline.process_stage.method.agent_sub_stages.third_party import (
+ ThirdPartyAgentSubStage,
+)
+from astrbot.core.pipeline.stage import Stage, registered_stages
+from astrbot.core.pipeline.stage_order import STAGES_ORDER
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+
+
+def _run_code_in_fresh_interpreter(code: str, failure_message: str) -> None:
+ proc = subprocess.run(
+ [sys.executable, "-c", code],
+ cwd=REPO_ROOT,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ assert proc.returncode == 0, (
+ f"{failure_message}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}\n"
+ )
+
+
+def test_smoke_critical_imports_in_fresh_interpreter() -> None:
+ code = (
+ "import importlib;"
+ "mods=["
+ "'astrbot.core.core_lifecycle',"
+ "'astrbot.core.astr_main_agent',"
+ "'astrbot.core.pipeline.scheduler',"
+ "'astrbot.core.pipeline.process_stage.method.agent_sub_stages.internal',"
+ "'astrbot.core.pipeline.process_stage.method.agent_sub_stages.third_party'"
+ "];"
+ "[importlib.import_module(m) for m in mods]"
+ )
+ _run_code_in_fresh_interpreter(code, "Smoke import check failed.")
+
+
+def test_smoke_pipeline_stage_registration_matches_order() -> None:
+ ensure_builtin_stages_registered()
+ stage_names = {cls.__name__ for cls in registered_stages}
+
+ assert set(STAGES_ORDER).issubset(stage_names)
+ assert len(stage_names) == len(registered_stages)
+
+
+def test_smoke_agent_sub_stages_are_stage_subclasses() -> None:
+ assert issubclass(InternalAgentSubStage, Stage)
+ assert issubclass(ThirdPartyAgentSubStage, Stage)
+
+
+def test_pipeline_package_exports_remain_compatible() -> None:
+ import astrbot.core.pipeline as pipeline
+
+ assert pipeline.ProcessStage is not None
+ assert pipeline.RespondStage is not None
+ assert isinstance(pipeline.STAGES_ORDER, list)
+ assert "ProcessStage" in pipeline.STAGES_ORDER
+
+
+def test_builtin_stage_bootstrap_is_idempotent() -> None:
+ ensure_builtin_stages_registered()
+ before_count = len(registered_stages)
+ stage_names = {cls.__name__ for cls in registered_stages}
+
+ expected_stage_names = {
+ "WakingCheckStage",
+ "WhitelistCheckStage",
+ "SessionStatusCheckStage",
+ "RateLimitStage",
+ "ContentSafetyCheckStage",
+ "PreProcessStage",
+ "ProcessStage",
+ "ResultDecorateStage",
+ "RespondStage",
+ }
+
+ assert expected_stage_names.issubset(stage_names)
+
+ ensure_builtin_stages_registered()
+ assert len(registered_stages) == before_count
+
+
+def test_pipeline_import_is_stable_with_mocked_apscheduler() -> None:
+ """Regression: importing pipeline should not require cron/apscheduler modules."""
+ code = (
+ "import sys;"
+ "from unittest.mock import MagicMock;"
+ "mock_apscheduler = MagicMock();"
+ "mock_apscheduler.schedulers = MagicMock();"
+ "mock_apscheduler.schedulers.asyncio = MagicMock();"
+ "mock_apscheduler.schedulers.background = MagicMock();"
+ "sys.modules['apscheduler'] = mock_apscheduler;"
+ "sys.modules['apscheduler.schedulers'] = mock_apscheduler.schedulers;"
+ "sys.modules['apscheduler.schedulers.asyncio'] = mock_apscheduler.schedulers.asyncio;"
+ "sys.modules['apscheduler.schedulers.background'] = mock_apscheduler.schedulers.background;"
+ "import astrbot.core.pipeline as pipeline;"
+ "assert pipeline.ProcessStage is not None;"
+ "assert pipeline.RespondStage is not None"
+ )
+ _run_code_in_fresh_interpreter(
+ code,
+ "Pipeline import should not depend on real apscheduler package.",
+ )
diff --git a/tests/test_temp_dir_cleaner.py b/tests/test_temp_dir_cleaner.py
new file mode 100644
index 000000000..01f3e65d0
--- /dev/null
+++ b/tests/test_temp_dir_cleaner.py
@@ -0,0 +1,52 @@
+import os
+import time
+from pathlib import Path
+
+from astrbot.core.utils.temp_dir_cleaner import TempDirCleaner, parse_size_to_bytes
+
+
+def test_parse_size_to_bytes():
+ assert parse_size_to_bytes("1024") == 1024 * 1024**2
+ assert parse_size_to_bytes(2048) == 2048 * 1024**2
+ assert parse_size_to_bytes("0.5") == int(0.5 * 1024**2)
+ assert parse_size_to_bytes(0) == 0
+ assert parse_size_to_bytes("invalid") == 0
+
+
+def _write_file(path: Path, size: int, mtime: float) -> None:
+ path.write_bytes(b"x" * size)
+ os.utime(path, (mtime, mtime))
+
+
+def test_cleanup_once_releases_30_percent_and_prefers_old_files(tmp_path):
+ temp_dir = tmp_path / "temp"
+ temp_dir.mkdir(parents=True, exist_ok=True)
+
+ base_time = time.time() - 1000
+ file_old = temp_dir / "old.bin"
+ file_mid = temp_dir / "mid.bin"
+ file_new = temp_dir / "new.bin"
+ _write_file(file_old, 400, base_time)
+ _write_file(file_mid, 300, base_time + 10)
+ _write_file(file_new, 300, base_time + 20)
+
+ cleaner = TempDirCleaner(max_size_getter=lambda: "0.0008", temp_dir=temp_dir)
+ cleaner.cleanup_once()
+
+ remaining_size = sum(f.stat().st_size for f in temp_dir.rglob("*") if f.is_file())
+ assert remaining_size <= 600
+ assert not file_old.exists()
+ assert file_mid.exists()
+ assert file_new.exists()
+
+
+def test_cleanup_once_noop_when_below_limit(tmp_path):
+ temp_dir = tmp_path / "temp"
+ temp_dir.mkdir(parents=True, exist_ok=True)
+ file_path = temp_dir / "a.bin"
+ _write_file(file_path, 100, time.time())
+
+ cleaner = TempDirCleaner(max_size_getter=lambda: "1", temp_dir=temp_dir)
+ cleaner.cleanup_once()
+
+ assert file_path.exists()
diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py
index f0e90002d..c8925416b 100644
--- a/tests/test_tool_loop_agent_runner.py
+++ b/tests/test_tool_loop_agent_runner.py
@@ -90,6 +90,43 @@ class MockToolExecutor:
return generator()
+class MockFailingProvider(MockProvider):
+ async def text_chat(self, **kwargs) -> LLMResponse:
+ self.call_count += 1
+ raise RuntimeError("primary provider failed")
+
+
+class MockErrProvider(MockProvider):
+ async def text_chat(self, **kwargs) -> LLMResponse:
+ self.call_count += 1
+ return LLMResponse(
+ role="err",
+ completion_text="primary provider returned error",
+ )
+
+
+class MockAbortableStreamProvider(MockProvider):
+ async def text_chat_stream(self, **kwargs):
+ abort_signal = kwargs.get("abort_signal")
+ yield LLMResponse(
+ role="assistant",
+ completion_text="partial ",
+ is_chunk=True,
+ )
+ if abort_signal and abort_signal.is_set():
+ yield LLMResponse(
+ role="assistant",
+ completion_text="partial ",
+ is_chunk=False,
+ )
+ return
+ yield LLMResponse(
+ role="assistant",
+ completion_text="partial final",
+ is_chunk=False,
+ )
+
+
class MockHooks(BaseAgentRunHooks):
"""模拟钩子函数"""
@@ -112,6 +149,20 @@ class MockHooks(BaseAgentRunHooks):
self.agent_done_called = True
+class MockEvent:
+ def __init__(self, umo: str, sender_id: str):
+ self.unified_msg_origin = umo
+ self._sender_id = sender_id
+
+ def get_sender_id(self):
+ return self._sender_id
+
+
+class MockAgentContext:
+ def __init__(self, event):
+ self.event = event
+
+
@pytest.fixture
def mock_provider():
return MockProvider()
@@ -321,6 +372,169 @@ async def test_hooks_called_with_max_step(
assert mock_hooks.tool_end_called, "on_tool_end应该被调用"
+@pytest.mark.asyncio
+async def test_fallback_provider_used_when_primary_raises(
+ runner, provider_request, mock_tool_executor, mock_hooks
+):
+ primary_provider = MockFailingProvider()
+ fallback_provider = MockProvider()
+ fallback_provider.should_call_tools = False
+
+ await runner.reset(
+ provider=primary_provider,
+ request=provider_request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ streaming=False,
+ fallback_providers=[fallback_provider],
+ )
+
+ async for _ in runner.step_until_done(5):
+ pass
+
+ final_resp = runner.get_final_llm_resp()
+ assert final_resp is not None
+ assert final_resp.role == "assistant"
+ assert final_resp.completion_text == "这是我的最终回答"
+ assert primary_provider.call_count == 1
+ assert fallback_provider.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_fallback_provider_used_when_primary_returns_err(
+ runner, provider_request, mock_tool_executor, mock_hooks
+):
+ primary_provider = MockErrProvider()
+ fallback_provider = MockProvider()
+ fallback_provider.should_call_tools = False
+
+ await runner.reset(
+ provider=primary_provider,
+ request=provider_request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ streaming=False,
+ fallback_providers=[fallback_provider],
+ )
+
+ async for _ in runner.step_until_done(5):
+ pass
+
+ final_resp = runner.get_final_llm_resp()
+ assert final_resp is not None
+ assert final_resp.role == "assistant"
+ assert final_resp.completion_text == "这是我的最终回答"
+ assert primary_provider.call_count == 1
+ assert fallback_provider.call_count == 1
+
+
+@pytest.mark.asyncio
+async def test_stop_signal_returns_aborted_and_persists_partial_message(
+ runner, provider_request, mock_tool_executor, mock_hooks
+):
+ provider = MockAbortableStreamProvider()
+
+ await runner.reset(
+ provider=provider,
+ request=provider_request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ streaming=True,
+ )
+
+ step_iter = runner.step()
+ first_resp = await step_iter.__anext__()
+ assert first_resp.type == "streaming_delta"
+
+ runner.request_stop()
+
+ rest_responses = []
+ async for response in step_iter:
+ rest_responses.append(response)
+
+ assert any(resp.type == "aborted" for resp in rest_responses)
+ assert runner.was_aborted() is True
+
+ final_resp = runner.get_final_llm_resp()
+ assert final_resp is not None
+ assert final_resp.role == "assistant"
+ assert final_resp.completion_text == "partial "
+ assert runner.run_context.messages[-1].role == "assistant"
+
+
+@pytest.mark.asyncio
+async def test_tool_result_injects_follow_up_notice(
+ runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
+):
+ mock_event = MockEvent("test:FriendMessage:follow_up", "u1")
+ run_context = ContextWrapper(context=MockAgentContext(mock_event))
+
+ await runner.reset(
+ provider=mock_provider,
+ request=provider_request,
+ run_context=run_context,
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ streaming=False,
+ )
+
+ ticket1 = runner.follow_up(
+ message_text="follow up 1",
+ )
+ ticket2 = runner.follow_up(
+ message_text="follow up 2",
+ )
+ assert ticket1 is not None
+ assert ticket2 is not None
+
+ async for _ in runner.step():
+ pass
+
+ assert provider_request.tool_calls_result is not None
+ assert isinstance(provider_request.tool_calls_result, list)
+ assert provider_request.tool_calls_result
+ tool_result = str(
+ provider_request.tool_calls_result[0].tool_calls_result[0].content
+ )
+ assert "SYSTEM NOTICE" in tool_result
+ assert "1. follow up 1" in tool_result
+ assert "2. follow up 2" in tool_result
+ assert ticket1.resolved.is_set() is True
+ assert ticket2.resolved.is_set() is True
+ assert ticket1.consumed is True
+ assert ticket2.consumed is True
+
+
+@pytest.mark.asyncio
+async def test_follow_up_ticket_not_consumed_when_no_next_tool_call(
+ runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
+):
+ mock_provider.should_call_tools = False
+ mock_event = MockEvent("test:FriendMessage:follow_up_no_tool", "u1")
+ run_context = ContextWrapper(context=MockAgentContext(mock_event))
+
+ await runner.reset(
+ provider=mock_provider,
+ request=provider_request,
+ run_context=run_context,
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ streaming=False,
+ )
+
+ ticket = runner.follow_up(message_text="follow up without tool")
+ assert ticket is not None
+
+ async for _ in runner.step():
+ pass
+
+ assert ticket.resolved.is_set() is True
+ assert ticket.consumed is False
+
+
if __name__ == "__main__":
# 运行测试
pytest.main([__file__, "-v"])
diff --git a/tests/unit/test_astr_message_event.py b/tests/unit/test_astr_message_event.py
new file mode 100644
index 000000000..ac529318f
--- /dev/null
+++ b/tests/unit/test_astr_message_event.py
@@ -0,0 +1,781 @@
+"""Tests for AstrMessageEvent class."""
+
+import re
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from astrbot.core.message.components import (
+ At,
+ AtAll,
+ Face,
+ Forward,
+ Image,
+ Plain,
+ Reply,
+)
+from astrbot.core.message.message_event_result import MessageEventResult
+from astrbot.core.platform.astr_message_event import AstrMessageEvent
+from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember
+from astrbot.core.platform.message_type import MessageType
+from astrbot.core.platform.platform_metadata import PlatformMetadata
+
+
+class ConcreteAstrMessageEvent(AstrMessageEvent):
+ """Concrete implementation of AstrMessageEvent for testing purposes."""
+
+ async def send(self, message):
+ """Send message implementation."""
+ await super().send(message)
+
+
+@pytest.fixture
+def platform_meta():
+ """Create platform metadata for testing."""
+ return PlatformMetadata(
+ name="test_platform",
+ description="Test platform",
+ id="test_platform_id",
+ )
+
+
+@pytest.fixture
+def message_member():
+ """Create a message member for testing."""
+ return MessageMember(user_id="user123", nickname="TestUser")
+
+
+@pytest.fixture
+def astrbot_message(message_member):
+ """Create an AstrBotMessage for testing."""
+ message = AstrBotMessage()
+ message.type = MessageType.FRIEND_MESSAGE
+ message.self_id = "bot123"
+ message.session_id = "session123"
+ message.message_id = "msg123"
+ message.sender = message_member
+ message.message = [Plain(text="Hello world")]
+ message.message_str = "Hello world"
+ message.raw_message = None
+ return message
+
+
+@pytest.fixture
+def astr_message_event(platform_meta, astrbot_message):
+ """Create an AstrMessageEvent instance for testing."""
+ return ConcreteAstrMessageEvent(
+ message_str="Hello world",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+
+
+class TestAstrMessageEventInit:
+ """Tests for AstrMessageEvent initialization."""
+
+ def test_init_basic(self, astr_message_event):
+ """Test basic AstrMessageEvent initialization."""
+ assert astr_message_event.message_str == "Hello world"
+ assert astr_message_event.role == "member"
+ assert astr_message_event.is_wake is False
+ assert astr_message_event.is_at_or_wake_command is False
+ assert astr_message_event._extras == {}
+ assert astr_message_event._result is None
+ assert astr_message_event.call_llm is False
+
+ def test_init_session(self, astr_message_event):
+ """Test session initialization."""
+ assert astr_message_event.session_id == "session123"
+ assert astr_message_event.session.platform_name == "test_platform_id"
+
+ def test_init_platform_reference(self, astr_message_event, platform_meta):
+ """Test platform reference initialization."""
+ assert astr_message_event.platform_meta == platform_meta
+ assert astr_message_event.platform == platform_meta # back compatibility
+
+ def test_init_created_at(self, astr_message_event):
+ """Test created_at timestamp is set."""
+ assert astr_message_event.created_at is not None
+ assert isinstance(astr_message_event.created_at, float)
+
+ def test_init_trace(self, astr_message_event):
+ """Test trace/span initialization."""
+ assert astr_message_event.trace is not None
+ assert astr_message_event.span is not None
+ assert astr_message_event.trace == astr_message_event.span
+
+
+class TestUnifiedMsgOrigin:
+ """Tests for unified_msg_origin property."""
+
+ def test_unified_msg_origin_getter(self, astr_message_event):
+ """Test unified_msg_origin getter."""
+ expected = "test_platform_id:FriendMessage:session123"
+ assert astr_message_event.unified_msg_origin == expected
+
+ def test_unified_msg_origin_setter(self, astr_message_event):
+ """Test unified_msg_origin setter."""
+ astr_message_event.unified_msg_origin = "new_platform:GroupMessage:new_session"
+
+ assert astr_message_event.session.platform_name == "new_platform"
+ assert astr_message_event.session.session_id == "new_session"
+
+
+class TestSessionId:
+ """Tests for session_id property."""
+
+ def test_session_id_getter(self, astr_message_event):
+ """Test session_id getter."""
+ assert astr_message_event.session_id == "session123"
+
+ def test_session_id_setter(self, astr_message_event):
+ """Test session_id setter."""
+ astr_message_event.session_id = "new_session_id"
+
+ assert astr_message_event.session_id == "new_session_id"
+
+
+class TestGetPlatformInfo:
+ """Tests for platform info methods."""
+
+ def test_get_platform_name(self, astr_message_event):
+ """Test get_platform_name method."""
+ assert astr_message_event.get_platform_name() == "test_platform"
+
+ def test_get_platform_id(self, astr_message_event):
+ """Test get_platform_id method."""
+ assert astr_message_event.get_platform_id() == "test_platform_id"
+
+
+class TestGetMessageInfo:
+ """Tests for message info methods."""
+
+ def test_get_message_str(self, astr_message_event):
+ """Test get_message_str method."""
+ assert astr_message_event.get_message_str() == "Hello world"
+
+ def test_get_message_str_none(self, platform_meta, astrbot_message):
+ """Test get_message_str keeps None when source message_str is None."""
+ astrbot_message.message_str = None
+ event = ConcreteAstrMessageEvent(
+ message_str=None,
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.get_message_str() is None
+
+ def test_get_messages(self, astr_message_event):
+ """Test get_messages method."""
+ messages = astr_message_event.get_messages()
+ assert len(messages) == 1
+ assert isinstance(messages[0], Plain)
+ assert messages[0].text == "Hello world"
+
+ def test_get_message_type(self, astr_message_event):
+ """Test get_message_type method."""
+ assert astr_message_event.get_message_type() == MessageType.FRIEND_MESSAGE
+
+ def test_get_session_id(self, astr_message_event):
+ """Test get_session_id method."""
+ assert astr_message_event.get_session_id() == "session123"
+
+ def test_get_group_id_empty_for_private(self, astr_message_event):
+ """Test get_group_id returns empty for private messages."""
+ assert astr_message_event.get_group_id() == ""
+
+ def test_get_self_id(self, astr_message_event):
+ """Test get_self_id method."""
+ assert astr_message_event.get_self_id() == "bot123"
+
+ def test_get_sender_id(self, astr_message_event):
+ """Test get_sender_id method."""
+ assert astr_message_event.get_sender_id() == "user123"
+
+ def test_get_sender_name(self, astr_message_event):
+ """Test get_sender_name method."""
+ assert astr_message_event.get_sender_name() == "TestUser"
+
+ def test_get_sender_name_empty_when_none(self, platform_meta, astrbot_message):
+ """Test get_sender_name returns empty string when nickname is None."""
+ astrbot_message.sender = MessageMember(user_id="user123", nickname=None)
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.get_sender_name() == ""
+
+ def test_get_sender_name_coerces_non_string(self, platform_meta, astrbot_message):
+ """Test get_sender_name stringifies non-string nickname values."""
+ astrbot_message.sender = MessageMember(user_id="user123", nickname=None)
+ astrbot_message.sender.nickname = 12345
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.get_sender_name() == "12345"
+
+
+class TestGetMessageOutline:
+ """Tests for get_message_outline method."""
+
+ def test_outline_plain_text(self, astr_message_event):
+ """Test outline with plain text message."""
+ outline = astr_message_event.get_message_outline()
+ assert "Hello world" in outline
+
+ def test_outline_with_image(self, platform_meta, astrbot_message):
+ """Test outline with image component."""
+ astrbot_message.message = [
+ Plain(text="Look at this"),
+ Image(file="http://example.com/img.jpg"),
+ ]
+ event = ConcreteAstrMessageEvent(
+ message_str="Look at this",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert "Look at this" in outline
+ assert "[图片]" in outline
+
+ def test_outline_with_at(self, platform_meta, astrbot_message):
+ """Test outline with At component."""
+ astrbot_message.message = [At(qq="12345"), Plain(text=" hello")]
+ event = ConcreteAstrMessageEvent(
+ message_str=" hello",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert "[At:12345]" in outline
+
+ def test_outline_with_at_all(self, platform_meta, astrbot_message):
+ """Test outline with AtAll component."""
+ astrbot_message.message = [AtAll()]
+ event = ConcreteAstrMessageEvent(
+ message_str="",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ # AtAll format is "[At:all]" in the actual implementation
+ assert "[At:" in outline and "all" in outline.lower()
+
+ def test_outline_with_face(self, platform_meta, astrbot_message):
+ """Test outline with Face component."""
+ astrbot_message.message = [Face(id="123")]
+ event = ConcreteAstrMessageEvent(
+ message_str="",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert "[表情:123]" in outline
+
+ def test_outline_with_forward(self, platform_meta, astrbot_message):
+ """Test outline with Forward component."""
+ # Forward requires an id parameter
+ astrbot_message.message = [Forward(id="test_forward_id")]
+ event = ConcreteAstrMessageEvent(
+ message_str="",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert "[转发消息]" in outline
+
+ def test_outline_with_reply(self, platform_meta, astrbot_message):
+ """Test outline with Reply component."""
+ # Reply requires an id parameter
+ reply = Reply(id="test_reply_id")
+ reply.message_str = "Original message"
+ reply.sender_nickname = "Sender"
+ astrbot_message.message = [reply, Plain(text=" reply")]
+ event = ConcreteAstrMessageEvent(
+ message_str=" reply",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert "[引用消息(Sender: Original message)]" in outline
+
+ def test_outline_with_reply_no_message(self, platform_meta, astrbot_message):
+ """Test outline with Reply component without message_str."""
+ # Reply requires an id parameter
+ reply = Reply(id="test_reply_id")
+ reply.message_str = None
+ astrbot_message.message = [reply]
+ event = ConcreteAstrMessageEvent(
+ message_str="",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert "[引用消息]" in outline
+
+ def test_outline_empty_chain(self, platform_meta, astrbot_message):
+ """Test outline with empty message chain."""
+ astrbot_message.message = []
+ event = ConcreteAstrMessageEvent(
+ message_str="",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert outline == ""
+
+ def test_outline_very_long_plain_text(self, platform_meta, astrbot_message):
+ """Test outline generation for very long plain text content."""
+ long_text = "A" * 20000
+ astrbot_message.message = [Plain(text=long_text)]
+ event = ConcreteAstrMessageEvent(
+ message_str=long_text,
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ outline = event.get_message_outline()
+ assert outline.startswith("A")
+ assert len(outline) >= 20000
+
+
+class TestExtras:
+ """Tests for extra information methods."""
+
+ def test_set_extra(self, astr_message_event):
+ """Test set_extra method."""
+ astr_message_event.set_extra("key1", "value1")
+ assert astr_message_event._extras["key1"] == "value1"
+
+ def test_get_extra_with_key(self, astr_message_event):
+ """Test get_extra with specific key."""
+ astr_message_event.set_extra("key1", "value1")
+ assert astr_message_event.get_extra("key1") == "value1"
+
+ def test_get_extra_with_default(self, astr_message_event):
+ """Test get_extra with default value."""
+ result = astr_message_event.get_extra("nonexistent", "default_value")
+ assert result == "default_value"
+
+ def test_get_extra_all(self, astr_message_event):
+ """Test get_extra without key returns all extras."""
+ astr_message_event.set_extra("key1", "value1")
+ astr_message_event.set_extra("key2", "value2")
+ all_extras = astr_message_event.get_extra()
+ assert all_extras == {"key1": "value1", "key2": "value2"}
+
+ def test_clear_extra(self, astr_message_event):
+ """Test clear_extra method."""
+ astr_message_event.set_extra("key1", "value1")
+ astr_message_event.clear_extra()
+ assert astr_message_event._extras == {}
+
+
+class TestSetResult:
+ """Tests for set_result method."""
+
+ def test_set_result_with_message_event_result(self, astr_message_event):
+ """Test set_result with MessageEventResult object."""
+ result = MessageEventResult().message("Test message")
+ astr_message_event.set_result(result)
+
+ assert astr_message_event._result == result
+
+ def test_set_result_with_string(self, astr_message_event):
+ """Test set_result with string creates MessageEventResult."""
+ astr_message_event.set_result("Test message")
+
+ assert astr_message_event._result is not None
+ assert len(astr_message_event._result.chain) == 1
+ assert isinstance(astr_message_event._result.chain[0], Plain)
+
+ def test_set_result_with_empty_chain(self, astr_message_event):
+ """Test set_result handles empty chain correctly."""
+ result = MessageEventResult()
+ # chain is already an empty list by default
+ astr_message_event.set_result(result)
+
+ assert astr_message_event._result.chain == []
+
+
+class TestStopContinueEvent:
+ """Tests for stop_event and continue_event methods."""
+
+ def test_stop_event_creates_result_if_none(self, astr_message_event):
+ """Test stop_event creates result if none exists."""
+ astr_message_event.stop_event()
+
+ assert astr_message_event._result is not None
+ assert astr_message_event.is_stopped() is True
+
+ def test_stop_event_with_existing_result(self, astr_message_event):
+ """Test stop_event with existing result."""
+ astr_message_event.set_result(MessageEventResult().message("Test"))
+ astr_message_event.stop_event()
+
+ assert astr_message_event.is_stopped() is True
+
+ def test_continue_event_creates_result_if_none(self, astr_message_event):
+ """Test continue_event creates result if none exists."""
+ astr_message_event.continue_event()
+
+ assert astr_message_event._result is not None
+ assert astr_message_event.is_stopped() is False
+
+ def test_continue_event_with_existing_result(self, astr_message_event):
+ """Test continue_event with existing result."""
+ astr_message_event.set_result(MessageEventResult().message("Test"))
+ astr_message_event.stop_event()
+ astr_message_event.continue_event()
+
+ assert astr_message_event.is_stopped() is False
+
+ def test_is_stopped_default_false(self, astr_message_event):
+ """Test is_stopped returns False by default."""
+ assert astr_message_event.is_stopped() is False
+
+
+class TestIsPrivateChat:
+ """Tests for is_private_chat method."""
+
+ def test_is_private_chat_true(self, astr_message_event):
+ """Test is_private_chat returns True for friend message."""
+ assert astr_message_event.is_private_chat() is True
+
+ def test_is_private_chat_false(self, platform_meta, astrbot_message):
+ """Test is_private_chat returns False for group message."""
+ astrbot_message.type = MessageType.GROUP_MESSAGE
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=astrbot_message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.is_private_chat() is False
+
+
+class TestIsWakeUp:
+ """Tests for is_wake_up method."""
+
+ def test_is_wake_up_default_false(self, astr_message_event):
+ """Test is_wake_up returns False by default."""
+ assert astr_message_event.is_wake_up() is False
+
+ def test_is_wake_up_when_set(self, astr_message_event):
+ """Test is_wake_up returns True when is_wake is set."""
+ astr_message_event.is_wake = True
+ assert astr_message_event.is_wake_up() is True
+
+
+class TestIsAdmin:
+ """Tests for is_admin method."""
+
+ def test_is_admin_default_false(self, astr_message_event):
+ """Test is_admin returns False by default."""
+ assert astr_message_event.is_admin() is False
+
+ def test_is_admin_when_admin(self, astr_message_event):
+ """Test is_admin returns True when role is admin."""
+ astr_message_event.role = "admin"
+ assert astr_message_event.is_admin() is True
+
+
+class TestProcessBuffer:
+ """Tests for process_buffer method."""
+
+ @pytest.mark.asyncio
+ async def test_process_buffer_splits_by_pattern(self, astr_message_event):
+ """Test process_buffer splits buffer by pattern."""
+ buffer = "Line 1\nLine 2\nLine 3\nRemaining"
+ pattern = re.compile(r".*\n")
+
+ with patch.object(
+ astr_message_event, "send", new_callable=AsyncMock
+ ) as mock_send:
+ result = await astr_message_event.process_buffer(buffer, pattern)
+
+ # Should have sent 3 lines and remaining should be "Remaining"
+ assert mock_send.call_count == 3
+ assert result == "Remaining"
+
+ @pytest.mark.asyncio
+ async def test_process_buffer_no_match(self, astr_message_event):
+ """Test process_buffer returns original when no match."""
+ buffer = "No newlines here"
+ pattern = re.compile(r"\n")
+
+ result = await astr_message_event.process_buffer(buffer, pattern)
+
+ assert result == "No newlines here"
+
+
+class TestResultHelpers:
+ """Tests for result helper methods."""
+
+ def test_make_result(self, astr_message_event):
+ """Test make_result creates empty MessageEventResult."""
+ result = astr_message_event.make_result()
+ assert isinstance(result, MessageEventResult)
+
+ def test_plain_result(self, astr_message_event):
+ """Test plain_result creates result with text."""
+ result = astr_message_event.plain_result("Hello")
+
+ assert isinstance(result, MessageEventResult)
+ assert len(result.chain) == 1
+ assert isinstance(result.chain[0], Plain)
+ assert result.chain[0].text == "Hello"
+
+ def test_image_result_url(self, astr_message_event):
+ """Test image_result with URL."""
+ result = astr_message_event.image_result("http://example.com/image.jpg")
+
+ assert isinstance(result, MessageEventResult)
+ assert len(result.chain) == 1
+ assert isinstance(result.chain[0], Image)
+
+ def test_image_result_path(self, astr_message_event):
+ """Test image_result with file path."""
+ result = astr_message_event.image_result("/path/to/image.jpg")
+
+ assert isinstance(result, MessageEventResult)
+ assert len(result.chain) == 1
+ assert isinstance(result.chain[0], Image)
+
+
+class TestGetResult:
+ """Tests for get_result and clear_result methods."""
+
+ def test_get_result_returns_none_by_default(self, astr_message_event):
+ """Test get_result returns None by default."""
+ assert astr_message_event.get_result() is None
+
+ def test_get_result_returns_set_result(self, astr_message_event):
+ """Test get_result returns set result."""
+ result = MessageEventResult().message("Test")
+ astr_message_event.set_result(result)
+
+ assert astr_message_event.get_result() == result
+
+ def test_clear_result(self, astr_message_event):
+ """Test clear_result clears the result."""
+ astr_message_event.set_result(MessageEventResult().message("Test"))
+ astr_message_event.clear_result()
+
+ assert astr_message_event.get_result() is None
+
+
+class TestShouldCallLlm:
+ """Tests for should_call_llm method."""
+
+ def test_should_call_llm_default(self, astr_message_event):
+ """Test call_llm default is False."""
+ assert astr_message_event.call_llm is False
+
+ def test_should_call_llm_when_set(self, astr_message_event):
+ """Test should_call_llm sets call_llm."""
+ astr_message_event.should_call_llm(True)
+ assert astr_message_event.call_llm is True
+
+
+class TestRequestLlm:
+ """Tests for request_llm method."""
+
+ def test_request_llm_basic(self, astr_message_event):
+ """Test request_llm creates ProviderRequest."""
+ request = astr_message_event.request_llm(prompt="Hello")
+
+ assert request.prompt == "Hello"
+ assert request.session_id == ""
+ assert request.image_urls == []
+ assert request.contexts == []
+
+ def test_request_llm_with_all_params(self, astr_message_event):
+ """Test request_llm with all parameters."""
+ request = astr_message_event.request_llm(
+ prompt="Hello",
+ session_id="session123",
+ image_urls=["http://example.com/img.jpg"],
+ contexts=[{"role": "user", "content": "Hi"}],
+ system_prompt="You are helpful",
+ )
+
+ assert request.prompt == "Hello"
+ assert request.session_id == "session123"
+ assert request.image_urls == ["http://example.com/img.jpg"]
+ assert request.contexts == [{"role": "user", "content": "Hi"}]
+ assert request.system_prompt == "You are helpful"
+
+
+class TestSendStreaming:
+ """Tests for send_streaming method."""
+
+ @pytest.mark.asyncio
+ async def test_send_streaming_sets_has_send_oper(self, astr_message_event):
+ """Test send_streaming sets _has_send_oper flag."""
+ assert astr_message_event._has_send_oper is False
+
+ async def generator():
+ yield MessageEventResult().message("Test")
+
+ with patch(
+ "astrbot.core.platform.astr_message_event.Metric.upload",
+ new_callable=AsyncMock,
+ ):
+ await astr_message_event.send_streaming(generator())
+
+ assert astr_message_event._has_send_oper is True
+
+
+class TestSendTyping:
+ """Tests for send_typing method."""
+
+ @pytest.mark.asyncio
+ async def test_send_typing_default_empty(self, astr_message_event):
+ """Test send_typing default implementation is empty."""
+ # Should not raise any exception
+ await astr_message_event.send_typing()
+
+
+class TestReact:
+ """Tests for react method."""
+
+ @pytest.mark.asyncio
+ async def test_react_sends_emoji(self, astr_message_event):
+ """Test react sends emoji as message."""
+ with patch.object(
+ astr_message_event, "send", new_callable=AsyncMock
+ ) as mock_send:
+ await astr_message_event.react("👍")
+
+ mock_send.assert_called_once()
+ call_arg = mock_send.call_args[0][0]
+ # MessageChain is a dataclass with chain attribute
+ assert len(call_arg.chain) == 1
+ assert isinstance(call_arg.chain[0], Plain)
+ assert call_arg.chain[0].text == "👍"
+
+
+class TestGetGroup:
+ """Tests for get_group method."""
+
+ @pytest.mark.asyncio
+ async def test_get_group_returns_none_for_private(self, astr_message_event):
+ """Test get_group returns None for private chat."""
+ result = await astr_message_event.get_group()
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_get_group_with_group_id_param(self, astr_message_event):
+ """Test get_group with group_id parameter."""
+ # Default implementation returns None
+ result = await astr_message_event.get_group(group_id="group123")
+ assert result is None
+
+
+class TestMessageTypeHandling:
+ """Tests for message type handling edge cases."""
+
+ def test_message_type_from_valid_string(self, platform_meta):
+ """Valid MessageType string should be converted correctly."""
+ message = AstrBotMessage()
+ message.type = "FRIEND_MESSAGE"
+ message.message = []
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.session.message_type == MessageType.FRIEND_MESSAGE
+ assert event.get_message_type() == MessageType.FRIEND_MESSAGE
+
+ def test_message_type_from_invalid_string_defaults_to_friend(self, platform_meta):
+ """Invalid message type should default to FRIEND_MESSAGE."""
+ message = AstrBotMessage()
+ message.type = "InvalidMessageType"
+ message.message = []
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.session.message_type == MessageType.FRIEND_MESSAGE
+ assert event.get_message_type() == MessageType.FRIEND_MESSAGE
+
+ def test_message_type_from_none_defaults_to_friend(self, platform_meta):
+ """None message type should default to FRIEND_MESSAGE."""
+ message = AstrBotMessage()
+ message.type = None
+ message.message = []
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.session.message_type == MessageType.FRIEND_MESSAGE
+ assert event.get_message_type() == MessageType.FRIEND_MESSAGE
+
+ def test_message_type_from_integer_defaults_to_friend(self, platform_meta):
+ """Integer message type should default to FRIEND_MESSAGE."""
+ message = AstrBotMessage()
+ message.type = 123
+ message.message = []
+ event = ConcreteAstrMessageEvent(
+ message_str="test",
+ message_obj=message,
+ platform_meta=platform_meta,
+ session_id="session123",
+ )
+ assert event.session.message_type == MessageType.FRIEND_MESSAGE
+ assert event.get_message_type() == MessageType.FRIEND_MESSAGE
+
+
+class TestDefensiveGetattr:
+ """Tests for defensive getattr behavior in AstrMessageEvent."""
+
+ def test_get_messages_without_message_attr(self, astr_message_event):
+ """get_messages should handle message_obj without 'message' attribute."""
+ astr_message_event.message_obj = type("DummyMessage", (), {})()
+ messages = astr_message_event.get_messages()
+ assert isinstance(messages, list)
+
+ def test_get_message_type_without_type_attr(self, astr_message_event):
+ """get_message_type should handle message_obj without 'type' attribute."""
+ astr_message_event.message_obj = type("DummyMessage", (), {})()
+ message_type = astr_message_event.get_message_type()
+ assert isinstance(message_type, MessageType)
+
+ def test_get_sender_fields_without_sender_attr(self, astr_message_event):
+ """get_sender_id and get_sender_name should handle missing 'sender'."""
+ astr_message_event.message_obj = type("DummyMessage", (), {})()
+ sender_id = astr_message_event.get_sender_id()
+ sender_name = astr_message_event.get_sender_name()
+ assert isinstance(sender_id, str)
+ assert isinstance(sender_name, str)
+
+ def test_get_message_type_with_non_enum_type(self, astr_message_event):
+ """get_message_type should handle message_obj.type that is not a MessageType."""
+ class DummyMessage:
+ def __init__(self):
+ self.type = "not_an_enum"
+ self.message = []
+ astr_message_event.message_obj = DummyMessage()
+ message_type = astr_message_event.get_message_type()
+ assert isinstance(message_type, MessageType)
diff --git a/tests/unit/test_astrbot_message.py b/tests/unit/test_astrbot_message.py
new file mode 100644
index 000000000..508a2727b
--- /dev/null
+++ b/tests/unit/test_astrbot_message.py
@@ -0,0 +1,268 @@
+"""Tests for AstrBotMessage and MessageMember classes."""
+
+import time
+from unittest.mock import patch
+
+from astrbot.core.message.components import Image, Plain
+from astrbot.core.platform.astrbot_message import AstrBotMessage, Group, MessageMember
+from astrbot.core.platform.message_type import MessageType
+
+
+class TestMessageMember:
+ """Tests for MessageMember dataclass."""
+
+ def test_message_member_creation_basic(self):
+ """Test creating a MessageMember with required fields."""
+ member = MessageMember(user_id="user123")
+
+ assert member.user_id == "user123"
+ assert member.nickname is None
+
+ def test_message_member_creation_with_nickname(self):
+ """Test creating a MessageMember with nickname."""
+ member = MessageMember(user_id="user123", nickname="TestUser")
+
+ assert member.user_id == "user123"
+ assert member.nickname == "TestUser"
+
+ def test_message_member_str_with_nickname(self):
+ """Test __str__ method with nickname."""
+ member = MessageMember(user_id="user123", nickname="TestUser")
+ result = str(member)
+
+ assert "User ID: user123" in result
+ assert "Nickname: TestUser" in result
+
+ def test_message_member_str_without_nickname(self):
+ """Test __str__ method without nickname."""
+ member = MessageMember(user_id="user123")
+ result = str(member)
+
+ assert "User ID: user123" in result
+ assert "Nickname: N/A" in result
+
+
+class TestGroup:
+ """Tests for Group dataclass."""
+
+ def test_group_creation_basic(self):
+ """Test creating a Group with required fields."""
+ group = Group(group_id="group123")
+
+ assert group.group_id == "group123"
+ assert group.group_name is None
+ assert group.group_avatar is None
+ assert group.group_owner is None
+ assert group.group_admins is None
+ assert group.members is None
+
+ def test_group_creation_with_all_fields(self):
+ """Test creating a Group with all fields."""
+ members = [MessageMember(user_id="user1"), MessageMember(user_id="user2")]
+ group = Group(
+ group_id="group123",
+ group_name="Test Group",
+ group_avatar="http://example.com/avatar.jpg",
+ group_owner="owner123",
+ group_admins=["admin1", "admin2"],
+ members=members,
+ )
+
+ assert group.group_id == "group123"
+ assert group.group_name == "Test Group"
+ assert group.group_avatar == "http://example.com/avatar.jpg"
+ assert group.group_owner == "owner123"
+ assert group.group_admins == ["admin1", "admin2"]
+ assert group.members == members
+
+ def test_group_str_with_all_fields(self):
+ """Test __str__ method with all fields."""
+ members = [MessageMember(user_id="user1", nickname="User One")]
+ group = Group(
+ group_id="group123",
+ group_name="Test Group",
+ group_avatar="http://example.com/avatar.jpg",
+ group_owner="owner123",
+ group_admins=["admin1"],
+ members=members,
+ )
+ result = str(group)
+
+ assert "Group ID: group123" in result
+ assert "Name: Test Group" in result
+ assert "Avatar: http://example.com/avatar.jpg" in result
+ assert "Owner ID: owner123" in result
+ assert "Admin IDs: ['admin1']" in result
+ assert "Members Len: 1" in result
+
+ def test_group_str_with_minimal_fields(self):
+ """Test __str__ method with minimal fields."""
+ group = Group(group_id="group123")
+ result = str(group)
+
+ assert "Group ID: group123" in result
+ assert "Name: N/A" in result
+ assert "Avatar: N/A" in result
+ assert "Owner ID: N/A" in result
+ assert "Admin IDs: N/A" in result
+ assert "Members Len: 0" in result
+ assert "First Member: N/A" in result
+
+
+class TestAstrBotMessage:
+ """Tests for AstrBotMessage class."""
+
+ def test_astrbot_message_creation(self):
+ """Test creating an AstrBotMessage."""
+ message = AstrBotMessage()
+
+ assert message.group is None
+ assert message.timestamp is not None
+ assert isinstance(message.timestamp, int)
+
+ def test_astrbot_message_timestamp(self):
+ """Test timestamp is set on creation."""
+ with patch.object(time, "time", return_value=1234567890):
+ message = AstrBotMessage()
+ assert message.timestamp == 1234567890
+
+ def test_astrbot_message_all_attributes(self):
+ """Test setting all attributes on AstrBotMessage."""
+ message = AstrBotMessage()
+ message.type = MessageType.FRIEND_MESSAGE
+ message.self_id = "bot123"
+ message.session_id = "session123"
+ message.message_id = "msg123"
+ message.sender = MessageMember(user_id="user123", nickname="TestUser")
+ message.message = [Plain(text="Hello")]
+ message.message_str = "Hello"
+ message.raw_message = {"raw": "data"}
+
+ assert message.type == MessageType.FRIEND_MESSAGE
+ assert message.self_id == "bot123"
+ assert message.session_id == "session123"
+ assert message.message_id == "msg123"
+ assert message.sender.user_id == "user123"
+ assert len(message.message) == 1
+ assert message.message_str == "Hello"
+ assert message.raw_message == {"raw": "data"}
+
+ def test_astrbot_message_str(self):
+ """Test __str__ method."""
+ message = AstrBotMessage()
+ message.type = MessageType.FRIEND_MESSAGE
+ message.self_id = "bot123"
+
+ result = str(message)
+ assert "'type'" in result
+ assert "'self_id'" in result
+
+
+class TestAstrBotMessageGroupId:
+ """Tests for AstrBotMessage group_id property."""
+
+ def test_group_id_returns_empty_when_no_group(self):
+ """Test group_id returns empty string when group is None."""
+ message = AstrBotMessage()
+ assert message.group_id == ""
+
+ def test_group_id_returns_group_id_when_group_exists(self):
+ """Test group_id returns the group's id when group exists."""
+ message = AstrBotMessage()
+ message.group = Group(group_id="group123")
+
+ assert message.group_id == "group123"
+
+ def test_group_id_setter_creates_new_group(self):
+ """Test group_id setter creates a new group if none exists."""
+ message = AstrBotMessage()
+ message.group_id = "new_group123"
+
+ assert message.group is not None
+ assert message.group.group_id == "new_group123"
+
+ def test_group_id_setter_updates_existing_group(self):
+ """Test group_id setter updates existing group's id."""
+ message = AstrBotMessage()
+ message.group = Group(group_id="old_group")
+ message.group_id = "new_group"
+
+ assert message.group.group_id == "new_group"
+
+ def test_group_id_setter_with_none_removes_group(self):
+ """Test group_id setter with None removes the group."""
+ message = AstrBotMessage()
+ message.group = Group(group_id="group123")
+ message.group_id = None
+
+ assert message.group is None
+
+ def test_group_id_setter_with_empty_string_removes_group(self):
+ """Test group_id setter with empty string removes the group."""
+ message = AstrBotMessage()
+ message.group = Group(group_id="group123")
+ message.group_id = ""
+
+ assert message.group is None
+
+
+class TestAstrBotMessageTypes:
+ """Tests for AstrBotMessage with different message types."""
+
+ def test_friend_message_type(self):
+ """Test AstrBotMessage with FRIEND_MESSAGE type."""
+ message = AstrBotMessage()
+ message.type = MessageType.FRIEND_MESSAGE
+
+ assert message.type == MessageType.FRIEND_MESSAGE
+ assert message.type.value == "FriendMessage"
+
+ def test_group_message_type(self):
+ """Test AstrBotMessage with GROUP_MESSAGE type."""
+ message = AstrBotMessage()
+ message.type = MessageType.GROUP_MESSAGE
+
+ assert message.type == MessageType.GROUP_MESSAGE
+ assert message.type.value == "GroupMessage"
+
+ def test_other_message_type(self):
+ """Test AstrBotMessage with OTHER_MESSAGE type."""
+ message = AstrBotMessage()
+ message.type = MessageType.OTHER_MESSAGE
+
+ assert message.type == MessageType.OTHER_MESSAGE
+ assert message.type.value == "OtherMessage"
+
+
+class TestAstrBotMessageChain:
+ """Tests for AstrBotMessage message chain."""
+
+ def test_message_chain_with_plain_text(self):
+ """Test message chain with plain text."""
+ message = AstrBotMessage()
+ message.message = [Plain(text="Hello world")]
+
+ assert len(message.message) == 1
+ assert isinstance(message.message[0], Plain)
+ assert message.message[0].text == "Hello world"
+
+ def test_message_chain_with_multiple_components(self):
+ """Test message chain with multiple components."""
+ message = AstrBotMessage()
+ message.message = [
+ Plain(text="Hello "),
+ Plain(text="world"),
+ Image(file="http://example.com/img.jpg"),
+ ]
+
+ assert len(message.message) == 3
+ assert isinstance(message.message[0], Plain)
+ assert isinstance(message.message[1], Plain)
+ assert isinstance(message.message[2], Image)
+
+ def test_message_chain_empty(self):
+ """Test empty message chain."""
+ message = AstrBotMessage()
+ message.message = []
+
+ assert len(message.message) == 0