mioku-plugin-chat 2.8.2 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -18,9 +18,6 @@ export const SETTINGS_CONFIG = {
18
18
  },
19
19
  audio: {
20
20
  enabled: true,
21
- baseUrl: "http://localhost:3939",
22
- apiKey: "fufu",
23
- timeoutMs: 40_000,
24
21
  },
25
22
  blacklistGroups: [],
26
23
  whitelistGroups: [],
package/context.ts CHANGED
@@ -31,6 +31,7 @@ import type {
31
31
  ChatRuntimeResult,
32
32
  ChatRuntimeInformationRequestOptions,
33
33
  } from "mioku";
34
+ import type { AudioServiceApi } from "mioku-service-audio";
34
35
  import type { ChatMessage } from "./types";
35
36
 
36
37
  export interface ChatPluginContext {
@@ -81,6 +82,7 @@ export interface ChatPluginContext {
81
82
  userMsg: ChatMessage,
82
83
  groupSessionId: string,
83
84
  ) => Promise<void>;
85
+ audioService?: AudioServiceApi;
84
86
  }
85
87
 
86
88
  export interface ChatRuntimeState {
package/core/base.ts CHANGED
@@ -2,6 +2,7 @@ import type { MiokiContext } from "mioki";
2
2
  import { logger } from "mioki";
3
3
  import type { SkillPermissionRole } from "mioku";
4
4
  import type { AIInstance, AIService } from "mioku";
5
+ import type { ScreenshotService } from "mioku";
5
6
  import type {
6
7
  ChatConfig,
7
8
  ChatMessage,
@@ -12,8 +13,9 @@ import type { ChatDatabase } from "../db";
12
13
  import type { HumanizeEngine } from "../humanize";
13
14
  import { parseLineMarkers, splitByReplyMarkers } from "../utils/queue";
14
15
  import { getGroupHistory } from "../utils";
15
- import type { ScreenshotService } from "mioku";
16
- import { synthesizeAudioBase64 } from "./media/audio";
16
+ import { getService, Services } from "mioku";
17
+ import { synthesizeAudioSource } from "./media/audio";
18
+ import type { AudioServiceApi } from "mioku-service-audio";
17
19
  import {
18
20
  extractStandaloneMarkdownBlock,
19
21
  splitOutgoingUnits,
@@ -28,6 +30,7 @@ export interface SendAIResponseOptions {
28
30
  config: ChatConfig;
29
31
  sentIndices?: Set<number>;
30
32
  onLineSent?: () => void | Promise<void>;
33
+ audioService?: AudioServiceApi;
31
34
  }
32
35
 
33
36
  const FAST_TYPING_BASE_MS = 150;
@@ -84,6 +87,7 @@ export async function sendAIResponse(
84
87
  config,
85
88
  sentIndices,
86
89
  onLineSent,
90
+ audioService,
87
91
  } = options;
88
92
  const typingDelayEnabled = config.enableTypingDelay ?? false;
89
93
  const enableMarkdownScreenshot = config.enableMarkdownScreenshot ?? true;
@@ -157,9 +161,7 @@ export async function sendAIResponse(
157
161
  }
158
162
 
159
163
  if (markdownContent) {
160
- const screenshotService = ctx.services?.screenshot as
161
- | ScreenshotService
162
- | undefined;
164
+ const screenshotService = getService(ctx, Services.Screenshot);
163
165
  const imagePath = await buildMarkdownImage(
164
166
  ctx,
165
167
  markdownContent,
@@ -206,6 +208,7 @@ export async function sendAIResponse(
206
208
  const audioSource = await resolveAudioSource(ctx, {
207
209
  audioText,
208
210
  config,
211
+ audioService,
209
212
  });
210
213
  const fallbackText = !audioSource && audioText ? audioText : undefined;
211
214
  if (sendableText) {
@@ -244,6 +247,7 @@ export async function sendMessage(
244
247
  text: string,
245
248
  config: ChatConfig,
246
249
  selfId: number,
250
+ audioService?: AudioServiceApi,
247
251
  ): Promise<void> {
248
252
  const typingDelayEnabled = config.enableTypingDelay ?? false;
249
253
  const enableMarkdownScreenshot = config.enableMarkdownScreenshot ?? true;
@@ -305,9 +309,7 @@ export async function sendMessage(
305
309
  const hasAt = atUsers.length > 0;
306
310
 
307
311
  if (markdownContent) {
308
- const screenshotService = ctx.services?.screenshot as
309
- | ScreenshotService
310
- | undefined;
312
+ const screenshotService = getService(ctx, Services.Screenshot);
311
313
  const imagePath = await buildMarkdownImage(
312
314
  ctx,
313
315
  markdownContent,
@@ -356,6 +358,7 @@ export async function sendMessage(
356
358
  const audioSource = await resolveAudioSource(ctx, {
357
359
  audioText,
358
360
  config,
361
+ audioService,
359
362
  });
360
363
  const fallbackText = !audioSource && audioText ? audioText : undefined;
361
364
 
@@ -613,6 +616,7 @@ async function resolveAudioSource(
613
616
  options: {
614
617
  audioText?: string;
615
618
  config: ChatConfig;
619
+ audioService?: AudioServiceApi;
616
620
  },
617
621
  ): Promise<string | null> {
618
622
  const trimmed = String(options.audioText || "").trim();
@@ -620,12 +624,16 @@ async function resolveAudioSource(
620
624
  return null;
621
625
  }
622
626
 
623
- if (!options.config.audio?.enabled || !options.config.audio.baseUrl?.trim()) {
627
+ if (!options.config.audio?.enabled) {
628
+ return null;
629
+ }
630
+
631
+ if (!options.audioService) {
624
632
  return null;
625
633
  }
626
634
 
627
635
  try {
628
- return await synthesizeAudioBase64(options.config.audio, trimmed);
636
+ return await synthesizeAudioSource(options.audioService, trimmed);
629
637
  } catch (error) {
630
638
  ctx.logger.error(
631
639
  `[audio] Failed to synthesize voice for "${trimmed}": ${error}`,
@@ -749,6 +757,7 @@ export interface BuildToolContextOptions {
749
757
  pendingImageUrls?: string[];
750
758
  humanize: HumanizeEngine;
751
759
  targetMessage: TargetMessage;
760
+ audioService?: AudioServiceApi;
752
761
  }
753
762
 
754
763
  function resolveTriggerSkillRole(
@@ -797,6 +806,7 @@ export function buildToolContext(
797
806
  pendingImageUrls,
798
807
  humanize,
799
808
  targetMessage,
809
+ audioService,
800
810
  } = options;
801
811
 
802
812
  return {
@@ -821,6 +831,7 @@ export function buildToolContext(
821
831
  content,
822
832
  config,
823
833
  selfId,
834
+ audioService,
824
835
  );
825
836
  },
826
837
  };
package/core/chat-turn.ts CHANGED
@@ -148,7 +148,7 @@ export async function finalizeChatTurn(
148
148
 
149
149
  if (groupId) {
150
150
  await pluginCtx.sendAIResponse(
151
- { ctx, groupId, messages: result.messages, config: cfg, sentIndices: toolCtx.sentMessageIndices },
151
+ { ctx, groupId, messages: result.messages, config: cfg, sentIndices: toolCtx.sentMessageIndices, audioService: pluginCtx.audioService },
152
152
  selfId,
153
153
  );
154
154
  await pluginCtx.sendEmoji(ctx, groupId, result.emojiPath, selfId);
@@ -162,7 +162,7 @@ export async function finalizeChatTurn(
162
162
  const sentIndices = toolCtx.sentMessageIndices;
163
163
  for (let i = 0; i < result.messages.length; i++) {
164
164
  if (sentIndices?.has(i)) continue;
165
- await pluginCtx.sendMessage(ctx, undefined, userId, result.messages[i], cfg, selfId);
165
+ await pluginCtx.sendMessage(ctx, undefined, userId, result.messages[i], cfg, selfId, pluginCtx.audioService);
166
166
  }
167
167
  if (result.emojiPath) {
168
168
  try {
@@ -1,4 +1,5 @@
1
1
  import type { AISkill, SkillPermissionRole } from "mioku";
2
+ import { normalizeSkillPermissionRole } from "mioku";
2
3
  import type { ChatConfig } from "../types";
3
4
 
4
5
  function normalizeSkillName(name: unknown): string {
@@ -33,24 +34,9 @@ const SKILL_PERMISSION_RANK: Record<SkillPermissionRole, number> = {
33
34
  member: 0,
34
35
  admin: 1,
35
36
  owner: 2,
37
+ master: 2,
36
38
  };
37
39
 
38
- export function normalizeSkillPermissionRole(
39
- role: unknown,
40
- ): SkillPermissionRole {
41
- const normalized = String(role || "")
42
- .trim()
43
- .toLowerCase();
44
- if (
45
- normalized === "member" ||
46
- normalized === "admin" ||
47
- normalized === "owner"
48
- ) {
49
- return normalized;
50
- }
51
- return "member";
52
- }
53
-
54
40
  export function getSkillRequiredPermissionRole(
55
41
  skill: AISkill | undefined,
56
42
  ): SkillPermissionRole {
@@ -1,73 +1,64 @@
1
- import type { AudioConfig } from "../../types";
1
+ import { readFile } from "node:fs/promises";
2
+ import type { MiokiContext } from "mioki";
3
+ import type { AudioServiceApi, SupportedLang } from "mioku-service-audio";
2
4
 
3
- interface TTSResponse {
4
- audio_base64?: string;
5
- detail?: string;
6
- error?: string;
7
- }
8
-
9
- function ensureTrailingSlash(value: string): string {
10
- return value.endsWith("/") ? value : `${value}/`;
11
- }
12
-
13
- function detectAudioLanguage(text: string): string {
14
- if (/[\u3040-\u30ff]/.test(text)) {
15
- return "all_ja";
16
- }
17
- if (/[\u4e00-\u9fff]/.test(text)) {
18
- return "all_zh";
19
- }
5
+ function detectAudioLanguage(text: string): SupportedLang {
6
+ if (/[\u3040-\u30ff]/.test(text)) return "ja";
7
+ if (/[\u4e00-\u9fff]/.test(text)) return "zh";
8
+ if (/[\uac00-\ud7af]/.test(text)) return "ko";
20
9
  return "en";
21
10
  }
22
11
 
23
- export async function synthesizeAudioBase64(
24
- config: AudioConfig,
12
+ export async function synthesizeAudioSource(
13
+ audioService: AudioServiceApi | undefined,
25
14
  text: string,
26
- ): Promise<string> {
27
- const trimmedText = String(text || "").trim();
28
- if (!config.enabled) {
29
- throw new Error("Audio output is disabled");
30
- }
31
- if (!trimmedText) {
32
- throw new Error("Audio text cannot be empty");
33
- }
34
- if (!config.baseUrl?.trim()) {
35
- throw new Error("Audio service baseUrl is not configured");
36
- }
37
-
38
- const endpoint = new URL("tts", ensureTrailingSlash(config.baseUrl.trim()));
39
- const headers: Record<string, string> = {
40
- "Content-Type": "application/json",
41
- };
15
+ ): Promise<string | null> {
16
+ const trimmed = String(text || "").trim();
17
+ if (!audioService) return null;
18
+ if (!trimmed) return null;
42
19
 
43
- const apiKey = config.apiKey?.trim();
44
- if (apiKey) {
45
- headers["X-API-Key"] = apiKey;
46
- }
20
+ const isReady = await audioService.ready().catch(() => false);
21
+ if (!isReady) return null;
47
22
 
48
- const response = await fetch(endpoint, {
49
- method: "POST",
50
- headers,
51
- body: JSON.stringify({
52
- text: trimmedText,
53
- text_lang: detectAudioLanguage(trimmedText),
54
- }),
55
- signal: AbortSignal.timeout(Math.max(1000, config.timeoutMs || 20_000)),
23
+ const result = await audioService.generateByText({
24
+ text: trimmed,
25
+ textLang: detectAudioLanguage(trimmed),
26
+ mediaType: "wav",
56
27
  });
57
28
 
58
- const payload = (await response.json().catch(() => ({}))) as TTSResponse;
59
- if (!response.ok) {
60
- throw new Error(
61
- payload?.detail ||
62
- payload?.error ||
63
- `TTS request failed with status ${response.status}`,
64
- );
65
- }
29
+ if (!result?.filePath) return null;
30
+ const filePath = result.filePath.replace(/^file:\/\//, "");
31
+ const buf = await readFile(filePath);
32
+ return `base64://${buf.toString("base64")}`;
33
+ }
66
34
 
67
- const audioBase64 = String(payload.audio_base64 || "").trim();
68
- if (!audioBase64) {
69
- throw new Error("TTS response missing audio_base64");
35
+ export async function sendVoice(
36
+ ctx: MiokiContext,
37
+ e: any,
38
+ filePath: string,
39
+ logTag = "[audio]",
40
+ ): Promise<void> {
41
+ const fileUrl = filePath.startsWith("file://")
42
+ ? filePath
43
+ : `file://${filePath}`;
44
+ try {
45
+ await e.reply([ctx.segment.record(fileUrl)]);
46
+ return;
47
+ } catch (err: any) {
48
+ const msg = String(err?.message ?? err);
49
+ if (
50
+ !/File URL path must be absolute|ENOENT|cannot access|No such file/i.test(
51
+ msg,
52
+ )
53
+ ) {
54
+ throw err;
55
+ }
56
+ const localPath = filePath.replace(/^file:\/\//, "");
57
+ const buf = await readFile(localPath);
58
+ const b64 = `base64://${buf.toString("base64")}`;
59
+ ctx.logger.info(
60
+ `${logTag} file:// 发送失败 (${msg}),回退 base64 (${buf.length} bytes)`,
61
+ );
62
+ await e.reply([ctx.segment.record(b64)]);
70
63
  }
71
-
72
- return `base64://${audioBase64}`;
73
64
  }
@@ -80,8 +80,8 @@ export function getForwardId(seg: {
80
80
  return String(seg?.id || seg?.data?.id || "");
81
81
  }
82
82
 
83
- export function getCardData(seg: { data?: unknown }): string | null {
84
- const data = seg?.data;
83
+ export function getCardData(seg: unknown): string | null {
84
+ const data = (seg as { data?: unknown } | null | undefined)?.data;
85
85
  if (!data) return null;
86
86
  return typeof data === "string" ? data : JSON.stringify(data);
87
87
  }
package/core/prompt.ts CHANGED
@@ -279,7 +279,7 @@ function buildResponseFormatSection(
279
279
  - Use [reply:123456] at the START of a line to quote-reply that message (123456 is message_id)
280
280
  - **You can use MULTIPLE [reply:xxx] markers in different lines to quote multiple messages!**`);
281
281
 
282
- if (ctx.config.audio?.enabled && ctx.config.audio.baseUrl?.trim()) {
282
+ if (ctx.config.audio?.enabled) {
283
283
  lines.push(`
284
284
  ### Optional Voice Message Format
285
285
  - You MAY optionally send one voice message by writing [audio:content]
@@ -525,7 +525,7 @@ function buildReviewGuidance(isMulti: boolean, length: Strength): string[] {
525
525
  function buildPokedGuidance(length: Strength): string[] {
526
526
  return [
527
527
  "Someone pokes you in a group, probably out of non-malicious play or to draw your attention to what happened in the group chat.",
528
- "Don't make a fuss about replying, just observe whether the chat history in the group has noteworthy content, and if not, simply say hello or express concern to the user.",
528
+ "Don't make a fuss about replying, just observe whether the chat history in the group has noteworthy content, and if not, simply say hello or express concern to the user. Don't stress about being poked.",
529
529
  POKED_LENGTH[length],
530
530
  ].filter(Boolean);
531
531
  }
@@ -1,4 +1,8 @@
1
1
  import type { ChatPluginContext, ChatHandlerState } from "../context";
2
+ import type {
3
+ GroupMessageEvent,
4
+ PrivateMessageEvent,
5
+ } from "napcat-sdk";
2
6
  import {
3
7
  isGroupAllowed,
4
8
  shouldTrigger,
@@ -21,6 +25,7 @@ import {
21
25
  summarizeHistoryVideo,
22
26
  } from "../core/media/history-media";
23
27
  import { handleIdleCheckDebug } from "./idle-debug";
28
+ import { handleTtsCommand } from "./tts-command";
24
29
  import { processChat } from "../core/chat-turn";
25
30
 
26
31
  const POKE_COOLDOWN_MS = 10 * 60_000;
@@ -32,7 +37,7 @@ export function createMessageHandler(
32
37
  const { ctx } = pluginCtx;
33
38
  const { getConfig, matchMessageCommands, runtimeState } = state;
34
39
 
35
- return async (e: any) => {
40
+ return async (e: GroupMessageEvent | PrivateMessageEvent) => {
36
41
  const isGroup = e.message_type === "group";
37
42
  const groupId: number | undefined = isGroup ? e.group_id : undefined;
38
43
  const cfg = await getConfig(groupId);
@@ -51,6 +56,12 @@ export function createMessageHandler(
51
56
  return;
52
57
  }
53
58
 
59
+ if (text.startsWith("/tts")) {
60
+ const payload = text.replace(/^\/tts\s*/, "");
61
+ await handleTtsCommand(pluginCtx, e, payload);
62
+ return;
63
+ }
64
+
54
65
  if (text === "/重置会话") {
55
66
  if (groupId) {
56
67
  pluginCtx.sessionManager.resetBotMessages(`group:${groupId}`);
package/handlers/poke.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ChatPluginContext, ChatHandlerState } from "../context";
2
2
  import type { TargetMessage } from "../types";
3
+ import type { GroupPokeNoticeEvent } from "napcat-sdk";
3
4
  import { getBotRole, isGroupAllowed } from "../utils";
4
5
  import { buildStructuredUserInputFromTarget } from "../manage/group-structured-history";
5
6
  import { finalizeChatTurn } from "../core/chat-turn";
@@ -12,7 +13,7 @@ export function createPokeHandler(
12
13
  const { ctx } = pluginCtx;
13
14
  const { getConfig, runtimeState, pokeCooldowns } = state;
14
15
 
15
- return async (e: any) => {
16
+ return async (e: GroupPokeNoticeEvent) => {
16
17
  if (e.target_id !== e.self_id) return;
17
18
  const groupId = e.group_id;
18
19
  const cfg = groupId ? await getConfig(groupId) : await getConfig();
@@ -36,7 +37,7 @@ export function createPokeHandler(
36
37
  "group",
37
38
  groupId,
38
39
  );
39
- const userId = e.user_id || e.operator_id;
40
+ const userId = e.user_id;
40
41
  const botRole = await getBotRole(groupId, ctx, e.self_id);
41
42
  const botNickname =
42
43
  cfg.nicknames[0] || ctx.pickBot(e.self_id).nickname || "Bot";
@@ -0,0 +1,67 @@
1
+ import type { SupportedLang } from "mioku-service-audio";
2
+ import type { ChatPluginContext } from "../context";
3
+ import { sendVoice } from "../core/media/audio";
4
+
5
+ function detectLang(text: string): SupportedLang {
6
+ if (/[\u3040-\u30ff]/.test(text)) return "ja";
7
+ if (/[\u4e00-\u9fff]/.test(text)) return "zh";
8
+ if (/[\uac00-\ud7af]/.test(text)) return "ko";
9
+ return "en";
10
+ }
11
+
12
+ export async function handleTtsCommand(
13
+ pluginCtx: ChatPluginContext,
14
+ e: any,
15
+ payload: string,
16
+ ): Promise<void> {
17
+ const { ctx } = pluginCtx;
18
+ const isOwner = ctx.isOwner?.(e) ?? false;
19
+ if (!isOwner) {
20
+ await e.reply("只有主人才能使用 /tts 指令~");
21
+ return;
22
+ }
23
+
24
+ const text = String(payload || "").trim();
25
+ if (!text) {
26
+ await e.reply("用法: /tts <要合成的文本>");
27
+ return;
28
+ }
29
+
30
+ const audioService = pluginCtx.audioService;
31
+ if (!audioService) {
32
+ await e.reply("audio 服务未加载,请安装 mioku-service-audio 后重启");
33
+ return;
34
+ }
35
+
36
+ const isReady = await audioService.ready().catch(() => false);
37
+ if (!isReady) {
38
+ ctx.logger.warn(
39
+ `[tts-cmd] audio 未就绪 status=${JSON.stringify(audioService.getStatus?.())}`,
40
+ );
41
+ await e.reply("audio 服务正在启动/失败,请稍后再试");
42
+ return;
43
+ }
44
+
45
+ ctx.logger.info(`[tts-cmd] 推理: "${text.slice(0, 40)}" lang=${detectLang(text)}`);
46
+
47
+ let result;
48
+ try {
49
+ result = await audioService.generateByText({
50
+ text,
51
+ textLang: detectLang(text),
52
+ mediaType: "wav",
53
+ });
54
+ } catch (err: any) {
55
+ ctx.logger.error(`[tts-cmd] 推理失败: ${err?.message ?? err}`);
56
+ await e.reply(`TTS 推理失败: ${err?.message ?? err}`);
57
+ return;
58
+ }
59
+
60
+ if (!result?.filePath) {
61
+ await e.reply("TTS 推理未返回音频文件");
62
+ return;
63
+ }
64
+
65
+ ctx.logger.info(`[tts-cmd] 发送 -> ${result.filePath}`);
66
+ await sendVoice(ctx, e, result.filePath, "[tts-cmd]");
67
+ }
package/index.ts CHANGED
@@ -1,8 +1,13 @@
1
- import { definePlugin } from "mioki";
2
- import type { MiokiContext } from "mioki";
3
- import type { AIInstance, AIModelRole, AIService, ConfigService, ScreenshotService } from "mioku";
4
- import { getPluginRuntimeState } from "mioku";
5
- import type { ChatConfig, ChatMessage, ChatGroupsFile, TargetMessage } from "./types";
1
+ import { definePlugin, type MiokiContext } from "mioki";
2
+ import type { AIInstance, AIModelRole, AIService } from "mioku";
3
+ import type { AudioServiceApi } from "mioku-service-audio";
4
+ import { getPluginRuntimeState, getService, Services } from "mioku";
5
+ import type {
6
+ ChatConfig,
7
+ ChatMessage,
8
+ ChatGroupsFile,
9
+ TargetMessage,
10
+ } from "./types";
6
11
  import { initDatabase } from "./db";
7
12
  import { SessionManager } from "./manage/session";
8
13
  import { RateLimiter } from "./manage/rate-limiter";
@@ -56,14 +61,10 @@ function resolveRoleInstances(aiService: AIService): {
56
61
  isMultimodal: boolean;
57
62
  } | null {
58
63
  const getByRole = (role: AIModelRole) =>
59
- aiService.getInstanceByRole?.(role) ??
60
- aiService.get?.(role) ??
61
- undefined;
64
+ aiService.getInstanceByRole?.(role) ?? aiService.get?.(role) ?? undefined;
62
65
 
63
66
  const main =
64
- getByRole("main") ??
65
- aiService.getDefault?.() ??
66
- aiService.get?.("main");
67
+ getByRole("main") ?? aiService.getDefault?.() ?? aiService.get?.("main");
67
68
  if (!main) return null;
68
69
 
69
70
  const work = getByRole("working") ?? aiService.get?.("work") ?? main;
@@ -78,9 +79,13 @@ function resolveRoleInstances(aiService: AIService): {
78
79
  const findModel = (role: AIModelRole, fallbackInstance: AIInstance) => {
79
80
  const full = bindings[role];
80
81
  if (full && full.includes("/")) return full.split("/").slice(1).join("/");
81
- const info = instances.find((item) => item.role === role || item.name === role);
82
+ const info = instances.find(
83
+ (item) => item.role === role || item.name === role,
84
+ );
82
85
  if (info?.modelId) return info.modelId;
83
- const anyInfo = instances.find((item) => item.name === (fallbackInstance as any).name);
86
+ const anyInfo = instances.find(
87
+ (item) => item.name === (fallbackInstance as any).name,
88
+ );
84
89
  return anyInfo?.modelId || "";
85
90
  };
86
91
 
@@ -115,16 +120,28 @@ export default definePlugin({
115
120
  async setup(ctx: MiokiContext) {
116
121
  ctx.logger.info("聊天插件正在初始化...");
117
122
 
118
- const aiService = ctx.services?.ai as AIService | undefined;
119
- const configService = ctx.services?.config as ConfigService | undefined;
120
- const screenshotService = ctx.services?.screenshot as ScreenshotService | undefined;
123
+ const aiService = getService(ctx, Services.AI);
124
+ const configService = getService(ctx, Services.Config);
125
+ const screenshotService = getService(ctx, Services.Screenshot);
126
+ const audioService = ctx.services.audio as AudioServiceApi | undefined;
121
127
  let warnedMarkdownScreenshotUnavailable = false;
128
+ if (!audioService) {
129
+ ctx.logger.warn("聊天插件检测到 audio 服务未安装 语音消息将不会发出");
130
+ }
122
131
 
123
132
  if (configService) {
124
133
  await configService.registerConfig("chat", "base", BASE_CONFIG);
125
134
  await configService.registerConfig("chat", "settings", SETTINGS_CONFIG);
126
- await configService.registerConfig("chat", "personalization", PERSONALIZATION_CONFIG);
127
- await configService.registerConfig("chat", "groups", DEFAULT_GROUPS_CONFIG);
135
+ await configService.registerConfig(
136
+ "chat",
137
+ "personalization",
138
+ PERSONALIZATION_CONFIG,
139
+ );
140
+ await configService.registerConfig(
141
+ "chat",
142
+ "groups",
143
+ DEFAULT_GROUPS_CONFIG,
144
+ );
128
145
  }
129
146
 
130
147
  let cachedBaseConfig: ChatConfig | null = null;
@@ -147,7 +164,8 @@ export default definePlugin({
147
164
  );
148
165
  }
149
166
  const base = (await configService.getConfig("chat", "base")) ?? {};
150
- const settings = (await configService.getConfig("chat", "settings")) ?? {};
167
+ const settings =
168
+ (await configService.getConfig("chat", "settings")) ?? {};
151
169
  const personalization =
152
170
  (await configService.getConfig("chat", "personalization")) ?? {};
153
171
  const mergedDefaults = mergeChatConfig(
@@ -172,20 +190,26 @@ export default definePlugin({
172
190
  if (!screenshotService && merged.enableMarkdownScreenshot) {
173
191
  merged.enableMarkdownScreenshot = false;
174
192
  if (!warnedMarkdownScreenshotUnavailable) {
175
- ctx.logger.warn("聊天插件未加载 screenshot 服务,Markdown 截图渲染已自动关闭");
193
+ ctx.logger.warn(
194
+ "聊天插件未加载 screenshot 服务,Markdown 截图渲染已自动关闭",
195
+ );
176
196
  warnedMarkdownScreenshotUnavailable = true;
177
197
  }
178
198
  }
179
199
  merged.whitelistGroups = normalizeIdList(merged.whitelistGroups);
180
200
  merged.blacklistGroups = normalizeIdList(merged.blacklistGroups);
181
- merged.mediaAnalysisBlacklistUsers =
182
- normalizeMediaAnalysisBlacklist(merged as Record<string, any>);
201
+ merged.mediaAnalysisBlacklistUsers = normalizeMediaAnalysisBlacklist(
202
+ merged as Record<string, any>,
203
+ );
183
204
  delete (merged as any).imageAnalysisBlacklistUsers;
184
205
 
185
206
  merged.model = roleModels.main || merged.model || "";
186
- merged.workingModel = roleModels.working || merged.workingModel || merged.model;
207
+ merged.workingModel =
208
+ roleModels.working || merged.workingModel || merged.model;
187
209
  merged.multimodalWorkingModel =
188
- roleModels.vision || merged.multimodalWorkingModel || merged.workingModel;
210
+ roleModels.vision ||
211
+ merged.multimodalWorkingModel ||
212
+ merged.workingModel;
189
213
  merged.isMultimodal = roleIsMultimodal;
190
214
  if (!roleIsMultimodal) merged.enableMediaRecognition = false;
191
215
  if (!merged.apiKey) merged.apiKey = "__ai_service__";
@@ -200,7 +224,10 @@ export default definePlugin({
200
224
  }
201
225
  const raw = await configService.getConfig("chat", "groups");
202
226
  cachedGroupsConfig =
203
- raw && typeof raw === "object" && raw.groups && typeof raw.groups === "object"
227
+ raw &&
228
+ typeof raw === "object" &&
229
+ raw.groups &&
230
+ typeof raw.groups === "object"
204
231
  ? (raw as ChatGroupsFile)
205
232
  : DEFAULT_GROUPS_CONFIG;
206
233
  };
@@ -276,7 +303,9 @@ export default definePlugin({
276
303
  apiUrl: legacyApiUrl,
277
304
  apiKey: legacyApiKey,
278
305
  modelType: "text",
279
- model: String((earlyConfig as any).workingModel || legacyModel || "default"),
306
+ model: String(
307
+ (earlyConfig as any).workingModel || legacyModel || "default",
308
+ ),
280
309
  });
281
310
  await aiService.create({
282
311
  name: "vision",
@@ -284,7 +313,9 @@ export default definePlugin({
284
313
  apiKey: legacyApiKey,
285
314
  modelType: "multimodal",
286
315
  model: String(
287
- (earlyConfig as any).multimodalWorkingModel || legacyModel || "default",
316
+ (earlyConfig as any).multimodalWorkingModel ||
317
+ legacyModel ||
318
+ "default",
288
319
  ),
289
320
  });
290
321
  aiService.setDefault("main");
@@ -364,7 +395,9 @@ export default definePlugin({
364
395
  const rateLimitGuard = new RateLimitGuard(rateLimiter, ctx.logger);
365
396
  const runWithRateLimitGuard = rateLimitGuard.run.bind(rateLimitGuard);
366
397
 
367
- const getAIInstance = (role: AIModelRole = "main"): AIInstance | undefined => {
398
+ const getAIInstance = (
399
+ role: AIModelRole = "main",
400
+ ): AIInstance | undefined => {
368
401
  if (role === "working") {
369
402
  return aiService.getInstanceByRole?.("working") ?? workAIInstance;
370
403
  }
@@ -375,12 +408,21 @@ export default definePlugin({
375
408
  };
376
409
 
377
410
  const pluginCtx = {
378
- ctx, defaultConfig: configProvider(), configProvider, getConfig,
411
+ ctx,
412
+ defaultConfig: configProvider(),
413
+ configProvider,
414
+ getConfig,
379
415
  db,
380
- aiInstance: mainAIInstance, workAIInstance, visionAIInstance,
416
+ aiInstance: mainAIInstance,
417
+ workAIInstance,
418
+ visionAIInstance,
381
419
  getAIInstance,
382
- aiService, humanize,
383
- sessionManager, skillManager, rateLimiter, queueManager,
420
+ aiService,
421
+ humanize,
422
+ sessionManager,
423
+ skillManager,
424
+ rateLimiter,
425
+ queueManager,
384
426
  groupStructuredHistory,
385
427
  cooldownManager: undefined as unknown as CooldownManager,
386
428
  idleCheckManager: undefined as unknown as IdleCheckManager,
@@ -388,28 +430,41 @@ export default definePlugin({
388
430
  sessionTurnScheduler,
389
431
  runWithRateLimitGuard,
390
432
  buildHistoryMediaOptions,
391
- getGroupHistoryMessages, getGroupInfoData, getHumanizeContexts,
392
- sendAIResponse, sendMessage, saveBotMessages, sendEmoji,
433
+ getGroupHistoryMessages,
434
+ getGroupInfoData,
435
+ getHumanizeContexts,
436
+ sendAIResponse,
437
+ sendMessage,
438
+ saveBotMessages,
439
+ sendEmoji,
393
440
  buildToolContext,
394
441
  buildStructuredUserInputFromTarget,
395
442
  runChat,
443
+ audioService,
396
444
  startCooldownTimer: (
397
445
  groupSessionId: string,
398
446
  groupId: number,
399
447
  selfId: number,
400
448
  ) => cooldownManager.startCooldownTimer(groupSessionId, groupId, selfId),
401
- async recordGroupMessageForLearning(userMsg: ChatMessage, groupSessionId: string) {
449
+ async recordGroupMessageForLearning(
450
+ userMsg: ChatMessage,
451
+ groupSessionId: string,
452
+ ) {
402
453
  db.saveMessage(userMsg);
403
454
  await Promise.all([
404
455
  humanize.expressionLearner.onMessage(userMsg),
405
456
  humanize.topicTracker.onMessage(groupSessionId),
406
457
  ]);
407
458
  if (userMsg.groupId) {
408
- const ttlMs = configProvider(userMsg.groupId).groupStructuredHistoryTtlMs;
459
+ const ttlMs = configProvider(
460
+ userMsg.groupId,
461
+ ).groupStructuredHistoryTtlMs;
409
462
  groupStructuredHistory.append(
410
463
  groupSessionId,
411
464
  buildStructuredUserMessages([
412
- buildStructuredUserInputFromTarget(userMsg as unknown as TargetMessage),
465
+ buildStructuredUserInputFromTarget(
466
+ userMsg as unknown as TargetMessage,
467
+ ),
413
468
  ]),
414
469
  ttlMs,
415
470
  );
@@ -424,8 +479,14 @@ export default definePlugin({
424
479
  const queueProcessor = new QueueProcessor(pluginCtx);
425
480
  pluginCtx.queueProcessor = queueProcessor;
426
481
 
427
- const cleanupInterval = setInterval(() => skillManager.cleanup(), 10 * 60_000);
428
- const dbCleanup = new ChatDatabaseCleanup(db, configProvider().retention ?? DEFAULT_CLEANUP_CONFIG);
482
+ const cleanupInterval = setInterval(
483
+ () => skillManager.cleanup(),
484
+ 10 * 60_000,
485
+ );
486
+ const dbCleanup = new ChatDatabaseCleanup(
487
+ db,
488
+ configProvider().retention ?? DEFAULT_CLEANUP_CONFIG,
489
+ );
429
490
  dbCleanup.start();
430
491
  idleCheckManager.start();
431
492
 
@@ -437,7 +498,11 @@ export default definePlugin({
437
498
  },
438
499
  matchMessageCommands: (
439
500
  getPluginRuntimeState("boot") as
440
- | { matchMessageCommands?: (text: string) => Array<{ plugin: string; command: string }> }
501
+ | {
502
+ matchMessageCommands?: (
503
+ text: string,
504
+ ) => Array<{ plugin: string; command: string }>;
505
+ }
441
506
  | undefined
442
507
  )?.matchMessageCommands,
443
508
  pokeCooldowns,
@@ -447,7 +512,7 @@ export default definePlugin({
447
512
  aiService.registerChatRuntime(runtime);
448
513
 
449
514
  ctx.handle("message", createMessageHandler(pluginCtx, handlerState));
450
- ctx.handle("notice.group.poke" as any, createPokeHandler(pluginCtx, handlerState));
515
+ ctx.handle("notice.group.poke", createPokeHandler(pluginCtx, handlerState));
451
516
 
452
517
  ctx.logger.info(
453
518
  `聊天插件加载成功 (main=${roleModels.main || "?"}, work=${roleModels.working || "?"}, vision=${roleModels.vision || "?"})`,
package/manage/types.ts CHANGED
@@ -71,6 +71,7 @@ export type SendMessage = (
71
71
  text: string,
72
72
  config: ChatConfig,
73
73
  selfId: number,
74
+ audioService?: import("mioku-service-audio").AudioServiceApi,
74
75
  ) => Promise<void>;
75
76
 
76
77
  export type BuildToolContext = (options: {
@@ -87,6 +88,7 @@ export type BuildToolContext = (options: {
87
88
  pendingImageUrls?: string[];
88
89
  humanize: HumanizeEngine;
89
90
  targetMessage: TargetMessage;
91
+ audioService?: import("mioku-service-audio").AudioServiceApi;
90
92
  }) => ToolContext;
91
93
 
92
94
  export type BuildStructuredUserInput = (event: any, content: string, fallbackTimestamp?: number) => StructuredUserInput;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mioku-plugin-chat",
3
- "version": "2.8.2",
3
+ "version": "2.9.0",
4
4
  "description": "AI 智能聊天插件",
5
5
  "main": "index.ts",
6
6
  "type": "module",
@@ -38,12 +38,18 @@
38
38
  "cmd": "/重置群会话",
39
39
  "desc": "重置当前群的AI聊天记录",
40
40
  "role": "admin"
41
+ },
42
+ {
43
+ "cmd": "/tts <文本>",
44
+ "desc": "TTS 推理",
45
+ "role": "owner"
41
46
  }
42
47
  ]
43
48
  }
44
49
  },
45
50
  "dependencies": {
46
51
  "openai": "^4.0.0",
52
+ "puppeteer": "^23.0.0",
47
53
  "sharp": "^0.34.5"
48
54
  },
49
55
  "devDependencies": {
@@ -52,7 +58,8 @@
52
58
  "mioku": "workspace:*"
53
59
  },
54
60
  "peerDependencies": {
55
- "mioku": "^0.8.0",
61
+ "mioku": "^0.9.0",
62
+ "mioku-service-audio": "workspace:*",
56
63
  "mioki": "^0.16.0"
57
64
  }
58
65
  }
package/types.ts CHANGED
@@ -154,11 +154,6 @@ export interface WebReaderConfig {
154
154
  */
155
155
  export interface AudioConfig {
156
156
  enabled: boolean;
157
- // 服务器URL
158
- baseUrl: string;
159
- // 密钥
160
- apiKey: string;
161
- timeoutMs: number;
162
157
  }
163
158
 
164
159
  export interface AIRequestLimitConfig {