@gakr-gakr/qqbot 0.1.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/api.ts +56 -0
- package/autobot.plugin.json +167 -0
- package/channel-plugin-api.ts +1 -0
- package/index.ts +33 -0
- package/package.json +64 -0
- package/runtime-api.ts +9 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/skills/qqbot-channel/SKILL.md +262 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-media/SKILL.md +37 -0
- package/skills/qqbot-remind/SKILL.md +153 -0
- package/src/bridge/approval/capability.ts +225 -0
- package/src/bridge/approval/handler-runtime.ts +204 -0
- package/src/bridge/bootstrap.ts +135 -0
- package/src/bridge/channel-entry.ts +18 -0
- package/src/bridge/commands/framework-context-adapter.ts +60 -0
- package/src/bridge/commands/framework-registration.ts +66 -0
- package/src/bridge/commands/from-parser.ts +60 -0
- package/src/bridge/commands/result-dispatcher.ts +76 -0
- package/src/bridge/config-shared.ts +132 -0
- package/src/bridge/config.ts +176 -0
- package/src/bridge/gateway.ts +178 -0
- package/src/bridge/logger.ts +31 -0
- package/src/bridge/narrowing.ts +31 -0
- package/src/bridge/plugin-version.ts +102 -0
- package/src/bridge/runtime.ts +25 -0
- package/src/bridge/sdk-adapter.ts +164 -0
- package/src/bridge/setup/finalize.ts +144 -0
- package/src/bridge/setup/surface.ts +34 -0
- package/src/bridge/tools/channel.ts +58 -0
- package/src/bridge/tools/index.ts +15 -0
- package/src/bridge/tools/remind.ts +91 -0
- package/src/channel.setup.ts +33 -0
- package/src/channel.ts +399 -0
- package/src/config-schema.ts +84 -0
- package/src/engine/access/index.ts +2 -0
- package/src/engine/access/resolve-policy.ts +30 -0
- package/src/engine/access/sender-match.ts +55 -0
- package/src/engine/access/types.ts +2 -0
- package/src/engine/adapter/audio.port.ts +27 -0
- package/src/engine/adapter/commands.port.ts +22 -0
- package/src/engine/adapter/history.port.ts +52 -0
- package/src/engine/adapter/index.ts +76 -0
- package/src/engine/adapter/mention-gate.port.ts +50 -0
- package/src/engine/adapter/types.ts +38 -0
- package/src/engine/api/api-client.ts +212 -0
- package/src/engine/api/media-chunked.ts +644 -0
- package/src/engine/api/media.ts +218 -0
- package/src/engine/api/messages.ts +293 -0
- package/src/engine/api/retry.ts +217 -0
- package/src/engine/api/routes.ts +95 -0
- package/src/engine/api/token.ts +277 -0
- package/src/engine/approval/index.ts +224 -0
- package/src/engine/commands/builtin/log-helpers.ts +341 -0
- package/src/engine/commands/builtin/register-all.ts +17 -0
- package/src/engine/commands/builtin/register-approve.ts +201 -0
- package/src/engine/commands/builtin/register-basic.ts +95 -0
- package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
- package/src/engine/commands/builtin/register-logs.ts +20 -0
- package/src/engine/commands/builtin/register-streaming.ts +138 -0
- package/src/engine/commands/builtin/state.ts +31 -0
- package/src/engine/commands/slash-command-auth.ts +88 -0
- package/src/engine/commands/slash-command-handler.ts +168 -0
- package/src/engine/commands/slash-command-test-support.ts +39 -0
- package/src/engine/commands/slash-commands-impl.ts +61 -0
- package/src/engine/commands/slash-commands.ts +202 -0
- package/src/engine/config/credential-backup.ts +108 -0
- package/src/engine/config/credentials.ts +76 -0
- package/src/engine/config/group.ts +227 -0
- package/src/engine/config/resolve.ts +283 -0
- package/src/engine/config/setup-logic.ts +84 -0
- package/src/engine/gateway/active-cfg.ts +52 -0
- package/src/engine/gateway/codec.ts +47 -0
- package/src/engine/gateway/constants.ts +117 -0
- package/src/engine/gateway/event-dispatcher.ts +177 -0
- package/src/engine/gateway/gateway-connection.ts +356 -0
- package/src/engine/gateway/gateway.ts +267 -0
- package/src/engine/gateway/inbound-attachments.ts +360 -0
- package/src/engine/gateway/inbound-context.ts +82 -0
- package/src/engine/gateway/inbound-pipeline.ts +171 -0
- package/src/engine/gateway/interaction-handler.ts +345 -0
- package/src/engine/gateway/message-queue.ts +404 -0
- package/src/engine/gateway/outbound-dispatch.ts +590 -0
- package/src/engine/gateway/reconnect.ts +199 -0
- package/src/engine/gateway/stages/access-stage.ts +99 -0
- package/src/engine/gateway/stages/assembly-stage.ts +156 -0
- package/src/engine/gateway/stages/content-stage.ts +77 -0
- package/src/engine/gateway/stages/envelope-stage.ts +144 -0
- package/src/engine/gateway/stages/group-gate-stage.ts +223 -0
- package/src/engine/gateway/stages/index.ts +18 -0
- package/src/engine/gateway/stages/quote-stage.ts +113 -0
- package/src/engine/gateway/stages/refidx-stage.ts +62 -0
- package/src/engine/gateway/stages/stub-contexts.ts +77 -0
- package/src/engine/gateway/types.ts +230 -0
- package/src/engine/gateway/typing-keepalive.ts +102 -0
- package/src/engine/gateway/ws-client.ts +16 -0
- package/src/engine/group/activation.ts +88 -0
- package/src/engine/group/history.ts +321 -0
- package/src/engine/group/mention.ts +114 -0
- package/src/engine/group/message-gating.ts +108 -0
- package/src/engine/messaging/decode-media-path.ts +82 -0
- package/src/engine/messaging/media-source.ts +210 -0
- package/src/engine/messaging/media-type-detect.ts +27 -0
- package/src/engine/messaging/outbound-audio-port.ts +38 -0
- package/src/engine/messaging/outbound-deliver.ts +810 -0
- package/src/engine/messaging/outbound-media-send.ts +658 -0
- package/src/engine/messaging/outbound-reply.ts +27 -0
- package/src/engine/messaging/outbound-result-helpers.ts +54 -0
- package/src/engine/messaging/outbound-types.ts +47 -0
- package/src/engine/messaging/outbound.ts +485 -0
- package/src/engine/messaging/reply-dispatcher.ts +597 -0
- package/src/engine/messaging/reply-limiter.ts +164 -0
- package/src/engine/messaging/sender.ts +741 -0
- package/src/engine/messaging/streaming-c2c.ts +1192 -0
- package/src/engine/messaging/streaming-media-send.ts +544 -0
- package/src/engine/messaging/target-parser.ts +104 -0
- package/src/engine/ref/format-message-ref.ts +142 -0
- package/src/engine/ref/format-ref-entry.ts +27 -0
- package/src/engine/ref/store.ts +211 -0
- package/src/engine/ref/types.ts +27 -0
- package/src/engine/session/known-users.ts +138 -0
- package/src/engine/session/session-store.ts +207 -0
- package/src/engine/tools/channel-api.ts +244 -0
- package/src/engine/tools/remind-logic.ts +377 -0
- package/src/engine/types.ts +313 -0
- package/src/engine/utils/attachment-tags.ts +174 -0
- package/src/engine/utils/audio.ts +525 -0
- package/src/engine/utils/data-paths.ts +38 -0
- package/src/engine/utils/diagnostics.ts +93 -0
- package/src/engine/utils/file-utils.ts +215 -0
- package/src/engine/utils/format.ts +70 -0
- package/src/engine/utils/image-size.ts +249 -0
- package/src/engine/utils/log.ts +77 -0
- package/src/engine/utils/media-tags.ts +177 -0
- package/src/engine/utils/payload.ts +157 -0
- package/src/engine/utils/platform.ts +265 -0
- package/src/engine/utils/request-context.ts +60 -0
- package/src/engine/utils/string-normalize.ts +91 -0
- package/src/engine/utils/stt.ts +103 -0
- package/src/engine/utils/text-parsing.ts +155 -0
- package/src/engine/utils/upload-cache.ts +96 -0
- package/src/engine/utils/voice-text.ts +15 -0
- package/src/exec-approvals.ts +237 -0
- package/src/qqbot-test-support.ts +29 -0
- package/src/secret-contract.ts +82 -0
- package/src/types.ts +210 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { UPLOAD_PREPARE_FALLBACK_CODE } from "../api/retry.js";
|
|
3
|
+
import { MediaFileType } from "../types.js";
|
|
4
|
+
import { formatFileSize, getFileTypeName, getMaxUploadSize } from "../utils/file-utils.js";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_MEDIA_SEND_ERROR,
|
|
7
|
+
OUTBOUND_ERROR_CODES,
|
|
8
|
+
type OutboundResult,
|
|
9
|
+
} from "./outbound-types.js";
|
|
10
|
+
import { UploadDailyLimitExceededError } from "./sender.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert a media send result into a user-facing message.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveUserFacingMediaError(
|
|
16
|
+
result: Pick<OutboundResult, "error" | "errorCode" | "qqBizCode">,
|
|
17
|
+
): string {
|
|
18
|
+
if (!result.error) {
|
|
19
|
+
return DEFAULT_MEDIA_SEND_ERROR;
|
|
20
|
+
}
|
|
21
|
+
if (result.qqBizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
|
|
22
|
+
return result.error;
|
|
23
|
+
}
|
|
24
|
+
switch (result.errorCode) {
|
|
25
|
+
case OUTBOUND_ERROR_CODES.FILE_TOO_LARGE:
|
|
26
|
+
case OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED:
|
|
27
|
+
return result.error;
|
|
28
|
+
default:
|
|
29
|
+
return DEFAULT_MEDIA_SEND_ERROR;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildDailyLimitExceededResult(err: UploadDailyLimitExceededError): OutboundResult {
|
|
34
|
+
const dir = path.dirname(err.filePath);
|
|
35
|
+
const name = path.basename(err.filePath);
|
|
36
|
+
const size = formatFileSize(err.fileSize);
|
|
37
|
+
return {
|
|
38
|
+
channel: "qqbot",
|
|
39
|
+
error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})`,
|
|
40
|
+
errorCode: OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED,
|
|
41
|
+
qqBizCode: UPLOAD_PREPARE_FALLBACK_CODE,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildFileTooLargeResult(fileType: MediaFileType, fileSize: number): OutboundResult {
|
|
46
|
+
const typeName = getFileTypeName(fileType);
|
|
47
|
+
const limit = getMaxUploadSize(fileType);
|
|
48
|
+
const limitMB = Math.round(limit / (1024 * 1024));
|
|
49
|
+
return {
|
|
50
|
+
channel: "qqbot",
|
|
51
|
+
error: `${typeName}过大(${formatFileSize(fileSize)}),超过了${limitMB}M,暂时不能通过QQ直接发给你。`,
|
|
52
|
+
errorCode: OUTBOUND_ERROR_CODES.FILE_TOO_LARGE,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { MessageReceipt } from "autobot/plugin-sdk/channel-message";
|
|
2
|
+
import type { GatewayAccount } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export interface OutboundContext {
|
|
5
|
+
to: string;
|
|
6
|
+
text: string;
|
|
7
|
+
accountId?: string | null;
|
|
8
|
+
replyToId?: string | null;
|
|
9
|
+
account: GatewayAccount;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface MediaOutboundContext extends OutboundContext {
|
|
13
|
+
mediaUrl: string;
|
|
14
|
+
mimeType?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Stable error codes for outbound media send results.
|
|
19
|
+
*/
|
|
20
|
+
export const OUTBOUND_ERROR_CODES = {
|
|
21
|
+
FILE_TOO_LARGE: "file_too_large",
|
|
22
|
+
UPLOAD_DAILY_LIMIT_EXCEEDED: "upload_daily_limit_exceeded",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export type OutboundErrorCode = (typeof OUTBOUND_ERROR_CODES)[keyof typeof OUTBOUND_ERROR_CODES];
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_MEDIA_SEND_ERROR = "发送失败,请稍后重试。";
|
|
28
|
+
|
|
29
|
+
export interface OutboundResult {
|
|
30
|
+
channel: string;
|
|
31
|
+
messageId?: string;
|
|
32
|
+
receipt?: MessageReceipt;
|
|
33
|
+
timestamp?: string | number;
|
|
34
|
+
error?: string;
|
|
35
|
+
errorCode?: OutboundErrorCode;
|
|
36
|
+
qqBizCode?: number;
|
|
37
|
+
refIdx?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normalized target information for media sends. */
|
|
41
|
+
export interface MediaTargetContext {
|
|
42
|
+
targetType: "c2c" | "group" | "channel" | "dm";
|
|
43
|
+
targetId: string;
|
|
44
|
+
account: GatewayAccount;
|
|
45
|
+
replyToId?: string;
|
|
46
|
+
logPrefix?: string;
|
|
47
|
+
}
|
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound messaging — aggregates reply limits, audio port, media sends, and text orchestration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { setOutboundAudioPort } from "./outbound-audio-port.js";
|
|
6
|
+
export type {
|
|
7
|
+
OutboundContext,
|
|
8
|
+
MediaOutboundContext,
|
|
9
|
+
OutboundResult,
|
|
10
|
+
OutboundErrorCode,
|
|
11
|
+
MediaTargetContext,
|
|
12
|
+
} from "./outbound-types.js";
|
|
13
|
+
export { OUTBOUND_ERROR_CODES, DEFAULT_MEDIA_SEND_ERROR } from "./outbound-types.js";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
checkMessageReplyLimit,
|
|
17
|
+
recordMessageReply,
|
|
18
|
+
getMessageReplyStats,
|
|
19
|
+
getMessageReplyConfig,
|
|
20
|
+
MESSAGE_REPLY_LIMIT,
|
|
21
|
+
} from "./outbound-reply.js";
|
|
22
|
+
export type { ReplyLimitResult } from "./outbound-reply.js";
|
|
23
|
+
|
|
24
|
+
export { resolveUserFacingMediaError } from "./outbound-result-helpers.js";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
buildMediaTarget,
|
|
28
|
+
parseTarget,
|
|
29
|
+
resolveOutboundMediaPath,
|
|
30
|
+
sendDocument,
|
|
31
|
+
sendPhoto,
|
|
32
|
+
sendVideoMsg,
|
|
33
|
+
sendVoice,
|
|
34
|
+
} from "./outbound-media-send.js";
|
|
35
|
+
|
|
36
|
+
import type { GatewayAccount } from "../types.js";
|
|
37
|
+
import { formatErrorMessage } from "../utils/format.js";
|
|
38
|
+
import { debugError, debugLog, debugWarn } from "../utils/log.js";
|
|
39
|
+
import { normalizeMediaTags } from "../utils/media-tags.js";
|
|
40
|
+
import { decodeCronPayload } from "../utils/payload.js";
|
|
41
|
+
import { normalizePath } from "../utils/platform.js";
|
|
42
|
+
import {
|
|
43
|
+
normalizeLowercaseStringOrEmpty,
|
|
44
|
+
normalizeOptionalString,
|
|
45
|
+
} from "../utils/string-normalize.js";
|
|
46
|
+
import {
|
|
47
|
+
isImageFile as coreIsImageFile,
|
|
48
|
+
isVideoFile as coreIsVideoFile,
|
|
49
|
+
} from "./media-type-detect.js";
|
|
50
|
+
import { isAudioFile } from "./outbound-audio-port.js";
|
|
51
|
+
import {
|
|
52
|
+
buildMediaTarget,
|
|
53
|
+
parseTarget,
|
|
54
|
+
resolveOutboundMediaPath,
|
|
55
|
+
sendDocument,
|
|
56
|
+
sendPhoto,
|
|
57
|
+
sendVideoMsg,
|
|
58
|
+
sendVoice,
|
|
59
|
+
} from "./outbound-media-send.js";
|
|
60
|
+
import {
|
|
61
|
+
checkMessageReplyLimit,
|
|
62
|
+
MESSAGE_REPLY_LIMIT,
|
|
63
|
+
recordMessageReply,
|
|
64
|
+
} from "./outbound-reply.js";
|
|
65
|
+
import type {
|
|
66
|
+
MediaOutboundContext,
|
|
67
|
+
MediaTargetContext,
|
|
68
|
+
OutboundContext,
|
|
69
|
+
OutboundResult,
|
|
70
|
+
} from "./outbound-types.js";
|
|
71
|
+
import {
|
|
72
|
+
initApiConfig,
|
|
73
|
+
accountToCreds,
|
|
74
|
+
sendText as senderSendText,
|
|
75
|
+
type DeliveryTarget,
|
|
76
|
+
} from "./sender.js";
|
|
77
|
+
|
|
78
|
+
const isImageFile = coreIsImageFile;
|
|
79
|
+
const isVideoFile = coreIsVideoFile;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Send text, optionally falling back from passive reply mode to proactive mode.
|
|
83
|
+
*
|
|
84
|
+
* Also supports inline media tags such as `<qqimg>...</qqimg>`.
|
|
85
|
+
*/
|
|
86
|
+
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
|
87
|
+
const { to, account } = ctx;
|
|
88
|
+
let { text, replyToId } = ctx;
|
|
89
|
+
let fallbackToProactive = false;
|
|
90
|
+
|
|
91
|
+
initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
|
|
92
|
+
|
|
93
|
+
debugLog(
|
|
94
|
+
"[qqbot] sendText ctx:",
|
|
95
|
+
JSON.stringify(
|
|
96
|
+
{ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId },
|
|
97
|
+
null,
|
|
98
|
+
2,
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (replyToId) {
|
|
103
|
+
const limitCheck = checkMessageReplyLimit(replyToId);
|
|
104
|
+
|
|
105
|
+
if (!limitCheck.allowed) {
|
|
106
|
+
if (limitCheck.shouldFallbackToProactive) {
|
|
107
|
+
debugWarn(
|
|
108
|
+
`[qqbot] sendText: passive reply unavailable, falling back to proactive send - ${limitCheck.message}`,
|
|
109
|
+
);
|
|
110
|
+
fallbackToProactive = true;
|
|
111
|
+
replyToId = null;
|
|
112
|
+
} else {
|
|
113
|
+
debugError(
|
|
114
|
+
`[qqbot] sendText: passive reply was blocked without a fallback path - ${limitCheck.message}`,
|
|
115
|
+
);
|
|
116
|
+
return {
|
|
117
|
+
channel: "qqbot",
|
|
118
|
+
error: limitCheck.message,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
debugLog(
|
|
123
|
+
`[qqbot] sendText: remaining passive replies for ${replyToId}: ${limitCheck.remaining}/${MESSAGE_REPLY_LIMIT}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
text = normalizeMediaTags(text);
|
|
129
|
+
|
|
130
|
+
const mediaTagRegex =
|
|
131
|
+
/<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
132
|
+
const mediaTagMatches = text.match(mediaTagRegex);
|
|
133
|
+
|
|
134
|
+
if (mediaTagMatches && mediaTagMatches.length > 0) {
|
|
135
|
+
debugLog(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`);
|
|
136
|
+
|
|
137
|
+
const sendQueue: Array<{
|
|
138
|
+
type: "text" | "image" | "voice" | "video" | "file" | "media";
|
|
139
|
+
content: string;
|
|
140
|
+
}> = [];
|
|
141
|
+
|
|
142
|
+
let lastIndex = 0;
|
|
143
|
+
const mediaTagRegexWithIndex =
|
|
144
|
+
/<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
145
|
+
let match;
|
|
146
|
+
|
|
147
|
+
while ((match = mediaTagRegexWithIndex.exec(text)) !== null) {
|
|
148
|
+
const textBefore = text
|
|
149
|
+
.slice(lastIndex, match.index)
|
|
150
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
151
|
+
.trim();
|
|
152
|
+
if (textBefore) {
|
|
153
|
+
sendQueue.push({ type: "text", content: textBefore });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const tagName = normalizeLowercaseStringOrEmpty(match[1]);
|
|
157
|
+
|
|
158
|
+
let mediaPath = normalizeOptionalString(match[2]) ?? "";
|
|
159
|
+
if (mediaPath.startsWith("MEDIA:")) {
|
|
160
|
+
mediaPath = mediaPath.slice("MEDIA:".length);
|
|
161
|
+
}
|
|
162
|
+
mediaPath = normalizePath(mediaPath);
|
|
163
|
+
|
|
164
|
+
mediaPath = mediaPath.replace(/\\\\/g, "\\");
|
|
165
|
+
|
|
166
|
+
const isWinLocal = /^[a-zA-Z]:[\\/]/.test(mediaPath) || mediaPath.startsWith("\\\\");
|
|
167
|
+
try {
|
|
168
|
+
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
|
|
169
|
+
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
|
|
170
|
+
|
|
171
|
+
if (!isWinLocal && (hasOctal || hasNonASCII)) {
|
|
172
|
+
debugLog(`[qqbot] sendText: Decoding path with mixed encoding: ${mediaPath}`);
|
|
173
|
+
|
|
174
|
+
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
|
|
175
|
+
return String.fromCharCode(Number.parseInt(octal, 8));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const bytes: number[] = [];
|
|
179
|
+
for (let i = 0; i < decoded.length; i++) {
|
|
180
|
+
const code = decoded.charCodeAt(i);
|
|
181
|
+
if (code <= 0xff) {
|
|
182
|
+
bytes.push(code);
|
|
183
|
+
} else {
|
|
184
|
+
const charBytes = Buffer.from(decoded[i], "utf8");
|
|
185
|
+
bytes.push(...charBytes);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const buffer = Buffer.from(bytes);
|
|
190
|
+
const utf8Decoded = buffer.toString("utf8");
|
|
191
|
+
|
|
192
|
+
if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) {
|
|
193
|
+
mediaPath = utf8Decoded;
|
|
194
|
+
debugLog(`[qqbot] sendText: Successfully decoded path: ${mediaPath}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch (decodeErr) {
|
|
198
|
+
debugError(
|
|
199
|
+
`[qqbot] sendText: Path decode error: ${
|
|
200
|
+
decodeErr instanceof Error ? decodeErr.message : JSON.stringify(decodeErr)
|
|
201
|
+
}`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (mediaPath) {
|
|
206
|
+
if (tagName === "qqmedia") {
|
|
207
|
+
sendQueue.push({ type: "media", content: mediaPath });
|
|
208
|
+
debugLog(`[qqbot] sendText: Found auto-detect media in <qqmedia>: ${mediaPath}`);
|
|
209
|
+
} else if (tagName === "qqvoice") {
|
|
210
|
+
sendQueue.push({ type: "voice", content: mediaPath });
|
|
211
|
+
debugLog(`[qqbot] sendText: Found voice path in <qqvoice>: ${mediaPath}`);
|
|
212
|
+
} else if (tagName === "qqvideo") {
|
|
213
|
+
sendQueue.push({ type: "video", content: mediaPath });
|
|
214
|
+
debugLog(`[qqbot] sendText: Found video URL in <qqvideo>: ${mediaPath}`);
|
|
215
|
+
} else if (tagName === "qqfile") {
|
|
216
|
+
sendQueue.push({ type: "file", content: mediaPath });
|
|
217
|
+
debugLog(`[qqbot] sendText: Found file path in <qqfile>: ${mediaPath}`);
|
|
218
|
+
} else {
|
|
219
|
+
sendQueue.push({ type: "image", content: mediaPath });
|
|
220
|
+
debugLog(`[qqbot] sendText: Found image path in <qqimg>: ${mediaPath}`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
lastIndex = match.index + match[0].length;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const textAfter = text
|
|
228
|
+
.slice(lastIndex)
|
|
229
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
230
|
+
.trim();
|
|
231
|
+
if (textAfter) {
|
|
232
|
+
sendQueue.push({ type: "text", content: textAfter });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
debugLog(`[qqbot] sendText: Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`);
|
|
236
|
+
|
|
237
|
+
const mediaTarget = buildMediaTarget({ to, account, replyToId });
|
|
238
|
+
let lastResult: OutboundResult = { channel: "qqbot" };
|
|
239
|
+
|
|
240
|
+
for (const item of sendQueue) {
|
|
241
|
+
try {
|
|
242
|
+
if (item.type === "text") {
|
|
243
|
+
const target = parseTarget(to);
|
|
244
|
+
const creds = accountToCreds(account);
|
|
245
|
+
const deliveryTarget: DeliveryTarget = {
|
|
246
|
+
type: target.type === "channel" ? "channel" : target.type,
|
|
247
|
+
id: target.id,
|
|
248
|
+
};
|
|
249
|
+
const result = await senderSendText(deliveryTarget, item.content, creds, {
|
|
250
|
+
msgId: replyToId ?? undefined,
|
|
251
|
+
});
|
|
252
|
+
if (replyToId) {
|
|
253
|
+
recordMessageReply(replyToId);
|
|
254
|
+
}
|
|
255
|
+
lastResult = {
|
|
256
|
+
channel: "qqbot",
|
|
257
|
+
messageId: result.id,
|
|
258
|
+
timestamp: result.timestamp,
|
|
259
|
+
refIdx: result.ext_info?.ref_idx,
|
|
260
|
+
};
|
|
261
|
+
debugLog(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`);
|
|
262
|
+
} else if (item.type === "image") {
|
|
263
|
+
lastResult = await sendPhoto(mediaTarget, item.content);
|
|
264
|
+
} else if (item.type === "voice") {
|
|
265
|
+
lastResult = await sendVoice(
|
|
266
|
+
mediaTarget,
|
|
267
|
+
item.content,
|
|
268
|
+
undefined,
|
|
269
|
+
account.config?.audioFormatPolicy?.transcodeEnabled !== false,
|
|
270
|
+
);
|
|
271
|
+
} else if (item.type === "video") {
|
|
272
|
+
lastResult = await sendVideoMsg(mediaTarget, item.content);
|
|
273
|
+
} else if (item.type === "file") {
|
|
274
|
+
lastResult = await sendDocument(mediaTarget, item.content);
|
|
275
|
+
} else if (item.type === "media") {
|
|
276
|
+
lastResult = await sendMedia({
|
|
277
|
+
to,
|
|
278
|
+
text: "",
|
|
279
|
+
mediaUrl: item.content,
|
|
280
|
+
accountId: account.accountId,
|
|
281
|
+
replyToId,
|
|
282
|
+
account,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
const errMsg = formatErrorMessage(err);
|
|
287
|
+
debugError(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`);
|
|
288
|
+
lastResult = { channel: "qqbot", error: errMsg };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return lastResult;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!replyToId) {
|
|
296
|
+
if (!text || text.trim().length === 0) {
|
|
297
|
+
debugError("[qqbot] sendText error: proactive message content cannot be empty");
|
|
298
|
+
return {
|
|
299
|
+
channel: "qqbot",
|
|
300
|
+
error: "Proactive messages require non-empty content (--message cannot be empty)",
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (fallbackToProactive) {
|
|
304
|
+
debugLog(
|
|
305
|
+
`[qqbot] sendText: [fallback] sending proactive message to ${to}, length=${text.length}`,
|
|
306
|
+
);
|
|
307
|
+
} else {
|
|
308
|
+
debugLog(`[qqbot] sendText: sending proactive message to ${to}, length=${text.length}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!account.appId || !account.clientSecret) {
|
|
313
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const target = parseTarget(to);
|
|
318
|
+
const creds = accountToCreds(account);
|
|
319
|
+
const deliveryTarget: DeliveryTarget = {
|
|
320
|
+
type: target.type === "channel" ? "channel" : target.type,
|
|
321
|
+
id: target.id,
|
|
322
|
+
};
|
|
323
|
+
debugLog("[qqbot] sendText target:", JSON.stringify(target));
|
|
324
|
+
|
|
325
|
+
const result = await senderSendText(deliveryTarget, text, creds, {
|
|
326
|
+
msgId: replyToId ?? undefined,
|
|
327
|
+
});
|
|
328
|
+
if (replyToId) {
|
|
329
|
+
recordMessageReply(replyToId);
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
channel: "qqbot",
|
|
333
|
+
messageId: result.id,
|
|
334
|
+
timestamp: result.timestamp,
|
|
335
|
+
refIdx: result.ext_info?.ref_idx,
|
|
336
|
+
};
|
|
337
|
+
} catch (err) {
|
|
338
|
+
const message = formatErrorMessage(err);
|
|
339
|
+
return { channel: "qqbot", error: message };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Send rich media, auto-routing by media type and source. */
|
|
344
|
+
export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
|
|
345
|
+
const { to, text, replyToId, account, mimeType } = ctx;
|
|
346
|
+
|
|
347
|
+
initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
|
|
348
|
+
|
|
349
|
+
if (!account.appId || !account.clientSecret) {
|
|
350
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
351
|
+
}
|
|
352
|
+
if (!ctx.mediaUrl) {
|
|
353
|
+
return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const resolvedMediaPath = resolveOutboundMediaPath(ctx.mediaUrl, "media", {
|
|
357
|
+
allowMissingLocalPath: true,
|
|
358
|
+
});
|
|
359
|
+
if (!resolvedMediaPath.ok) {
|
|
360
|
+
return { channel: "qqbot", error: resolvedMediaPath.error };
|
|
361
|
+
}
|
|
362
|
+
const mediaUrl = resolvedMediaPath.mediaPath;
|
|
363
|
+
|
|
364
|
+
const target = buildMediaTarget({ to, account, replyToId });
|
|
365
|
+
|
|
366
|
+
if (isAudioFile(mediaUrl, mimeType)) {
|
|
367
|
+
const formats =
|
|
368
|
+
account.config?.audioFormatPolicy?.uploadDirectFormats ??
|
|
369
|
+
account.config?.voiceDirectUploadFormats;
|
|
370
|
+
const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
|
|
371
|
+
const result = await sendVoice(target, mediaUrl, formats, transcodeEnabled);
|
|
372
|
+
if (!result.error) {
|
|
373
|
+
if (text?.trim()) {
|
|
374
|
+
await sendTextAfterMedia(target, text);
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
const voiceError = result.error;
|
|
379
|
+
debugWarn(`[qqbot] sendMedia: sendVoice failed (${voiceError}), falling back to sendDocument`);
|
|
380
|
+
const fallback = await sendDocument(target, mediaUrl);
|
|
381
|
+
if (!fallback.error) {
|
|
382
|
+
if (text?.trim()) {
|
|
383
|
+
await sendTextAfterMedia(target, text);
|
|
384
|
+
}
|
|
385
|
+
return fallback;
|
|
386
|
+
}
|
|
387
|
+
return { channel: "qqbot", error: `voice: ${voiceError} | fallback file: ${fallback.error}` };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (isVideoFile(mediaUrl, mimeType)) {
|
|
391
|
+
const result = await sendVideoMsg(target, mediaUrl);
|
|
392
|
+
if (!result.error && text?.trim()) {
|
|
393
|
+
await sendTextAfterMedia(target, text);
|
|
394
|
+
}
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (
|
|
399
|
+
!isImageFile(mediaUrl, mimeType) &&
|
|
400
|
+
!isAudioFile(mediaUrl, mimeType) &&
|
|
401
|
+
!isVideoFile(mediaUrl, mimeType)
|
|
402
|
+
) {
|
|
403
|
+
const result = await sendDocument(target, mediaUrl);
|
|
404
|
+
if (!result.error && text?.trim()) {
|
|
405
|
+
await sendTextAfterMedia(target, text);
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const result = await sendPhoto(target, mediaUrl);
|
|
411
|
+
if (!result.error && text?.trim()) {
|
|
412
|
+
await sendTextAfterMedia(target, text);
|
|
413
|
+
}
|
|
414
|
+
return result;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function sendTextAfterMedia(ctx: MediaTargetContext, text: string): Promise<void> {
|
|
418
|
+
try {
|
|
419
|
+
const creds = accountToCreds(ctx.account);
|
|
420
|
+
const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
|
|
421
|
+
await senderSendText(target, text, creds, { msgId: ctx.replyToId });
|
|
422
|
+
} catch (err) {
|
|
423
|
+
debugError(`[qqbot] sendTextAfterMedia failed: ${formatErrorMessage(err)}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function sendProactiveMessage(
|
|
428
|
+
account: GatewayAccount,
|
|
429
|
+
to: string,
|
|
430
|
+
content: string,
|
|
431
|
+
): Promise<OutboundResult> {
|
|
432
|
+
return sendText({ account, to, text: content });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export async function sendCronMessage(
|
|
436
|
+
account: GatewayAccount,
|
|
437
|
+
to: string,
|
|
438
|
+
message: string,
|
|
439
|
+
): Promise<OutboundResult> {
|
|
440
|
+
const timestamp = new Date().toISOString();
|
|
441
|
+
debugLog(`[${timestamp}] [qqbot] sendCronMessage: to=${to}, message length=${message.length}`);
|
|
442
|
+
|
|
443
|
+
const cronResult = decodeCronPayload(message);
|
|
444
|
+
|
|
445
|
+
if (cronResult.isCronPayload) {
|
|
446
|
+
if (cronResult.error) {
|
|
447
|
+
debugError(
|
|
448
|
+
`[${timestamp}] [qqbot] sendCronMessage: cron payload decode error: ${cronResult.error}`,
|
|
449
|
+
);
|
|
450
|
+
return {
|
|
451
|
+
channel: "qqbot",
|
|
452
|
+
error: `Failed to decode cron payload: ${cronResult.error}`,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (cronResult.payload) {
|
|
457
|
+
const payload = cronResult.payload;
|
|
458
|
+
debugLog(
|
|
459
|
+
`[${timestamp}] [qqbot] sendCronMessage: decoded cron payload, targetType=${payload.targetType}, targetAddress=${payload.targetAddress}, content length=${payload.content.length}`,
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
const targetTo =
|
|
463
|
+
payload.targetType === "group" ? `group:${payload.targetAddress}` : payload.targetAddress;
|
|
464
|
+
|
|
465
|
+
debugLog(
|
|
466
|
+
`[${timestamp}] [qqbot] sendCronMessage: sending proactive message to targetTo=${targetTo}`,
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const result = await sendText({ account, to: targetTo, text: payload.content });
|
|
470
|
+
|
|
471
|
+
if (result.error) {
|
|
472
|
+
debugError(
|
|
473
|
+
`[${timestamp}] [qqbot] sendCronMessage: proactive message failed, error=${result.error}`,
|
|
474
|
+
);
|
|
475
|
+
} else {
|
|
476
|
+
debugLog(`[${timestamp}] [qqbot] sendCronMessage: proactive message sent successfully`);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return result;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
debugLog(`[${timestamp}] [qqbot] sendCronMessage: plain text message, sending to ${to}`);
|
|
484
|
+
return await sendText({ account, to, text: message });
|
|
485
|
+
}
|