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.
- package/configs/settings.ts +0 -3
- package/context.ts +2 -0
- package/core/base.ts +21 -10
- package/core/chat-turn.ts +2 -2
- package/core/external-skills.ts +2 -16
- package/core/media/audio.ts +52 -61
- package/core/media/segment.ts +2 -2
- package/core/prompt.ts +2 -2
- package/handlers/message.ts +12 -1
- package/handlers/poke.ts +3 -2
- package/handlers/tts-command.ts +67 -0
- package/index.ts +106 -41
- package/manage/types.ts +2 -0
- package/package.json +9 -2
- package/types.ts +0 -5
package/configs/settings.ts
CHANGED
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
|
|
16
|
-
import {
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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 {
|
package/core/external-skills.ts
CHANGED
|
@@ -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 {
|
package/core/media/audio.ts
CHANGED
|
@@ -1,73 +1,64 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
24
|
-
|
|
12
|
+
export async function synthesizeAudioSource(
|
|
13
|
+
audioService: AudioServiceApi | undefined,
|
|
25
14
|
text: string,
|
|
26
|
-
): Promise<string> {
|
|
27
|
-
const
|
|
28
|
-
if (!
|
|
29
|
-
|
|
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
|
|
44
|
-
if (
|
|
45
|
-
headers["X-API-Key"] = apiKey;
|
|
46
|
-
}
|
|
20
|
+
const isReady = await audioService.ready().catch(() => false);
|
|
21
|
+
if (!isReady) return null;
|
|
47
22
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
}
|
package/core/media/segment.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
}
|
package/handlers/message.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
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 {
|
|
3
|
-
import type {
|
|
4
|
-
import { getPluginRuntimeState } from "mioku";
|
|
5
|
-
import type {
|
|
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(
|
|
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(
|
|
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.
|
|
119
|
-
const configService = ctx.
|
|
120
|
-
const screenshotService = ctx.
|
|
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(
|
|
127
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
207
|
+
merged.workingModel =
|
|
208
|
+
roleModels.working || merged.workingModel || merged.model;
|
|
187
209
|
merged.multimodalWorkingModel =
|
|
188
|
-
roleModels.vision ||
|
|
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 &&
|
|
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(
|
|
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 ||
|
|
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 = (
|
|
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,
|
|
411
|
+
ctx,
|
|
412
|
+
defaultConfig: configProvider(),
|
|
413
|
+
configProvider,
|
|
414
|
+
getConfig,
|
|
379
415
|
db,
|
|
380
|
-
aiInstance: mainAIInstance,
|
|
416
|
+
aiInstance: mainAIInstance,
|
|
417
|
+
workAIInstance,
|
|
418
|
+
visionAIInstance,
|
|
381
419
|
getAIInstance,
|
|
382
|
-
aiService,
|
|
383
|
-
|
|
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,
|
|
392
|
-
|
|
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(
|
|
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(
|
|
459
|
+
const ttlMs = configProvider(
|
|
460
|
+
userMsg.groupId,
|
|
461
|
+
).groupStructuredHistoryTtlMs;
|
|
409
462
|
groupStructuredHistory.append(
|
|
410
463
|
groupSessionId,
|
|
411
464
|
buildStructuredUserMessages([
|
|
412
|
-
buildStructuredUserInputFromTarget(
|
|
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(
|
|
428
|
-
|
|
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
|
-
| {
|
|
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"
|
|
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.
|
|
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.
|
|
61
|
+
"mioku": "^0.9.0",
|
|
62
|
+
"mioku-service-audio": "workspace:*",
|
|
56
63
|
"mioki": "^0.16.0"
|
|
57
64
|
}
|
|
58
65
|
}
|
package/types.ts
CHANGED