@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,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media upload API for the QQ Open Platform (small-file direct upload).
|
|
3
|
+
*
|
|
4
|
+
* Key improvements:
|
|
5
|
+
* - Unified `uploadMedia(scope, ...)` replaces `uploadC2CMedia` + `uploadGroupMedia`.
|
|
6
|
+
* - Upload cache integration via composition (passed in constructor).
|
|
7
|
+
* - Uses `withRetry` from the shared retry engine.
|
|
8
|
+
*
|
|
9
|
+
* Chunked upload for files above `LARGE_FILE_THRESHOLD` is tracked by
|
|
10
|
+
* {@link ./media-chunked.ts}; this module currently handles only the
|
|
11
|
+
* one-shot path.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import {
|
|
16
|
+
MediaFileType,
|
|
17
|
+
type ChatScope,
|
|
18
|
+
type UploadMediaResponse,
|
|
19
|
+
type MessageResponse,
|
|
20
|
+
type EngineLogger,
|
|
21
|
+
} from "../types.js";
|
|
22
|
+
import { ApiClient } from "./api-client.js";
|
|
23
|
+
import { withRetry, UPLOAD_RETRY_POLICY } from "./retry.js";
|
|
24
|
+
import { mediaUploadPath, messagePath, getNextMsgSeq } from "./routes.js";
|
|
25
|
+
import { TokenManager } from "./token.js";
|
|
26
|
+
|
|
27
|
+
/** Upload cache interface — the caller provides the implementation. */
|
|
28
|
+
export interface UploadCacheAdapter {
|
|
29
|
+
computeHash: (data: string) => string;
|
|
30
|
+
get: (hash: string, scope: string, targetId: string, fileType: number) => string | null;
|
|
31
|
+
set: (
|
|
32
|
+
hash: string,
|
|
33
|
+
scope: string,
|
|
34
|
+
targetId: string,
|
|
35
|
+
fileType: number,
|
|
36
|
+
fileInfo: string,
|
|
37
|
+
fileUuid: string,
|
|
38
|
+
ttl: number,
|
|
39
|
+
) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** File name sanitizer — injected to avoid importing platform-specific utils. */
|
|
43
|
+
export type SanitizeFileNameFn = (name: string) => string;
|
|
44
|
+
|
|
45
|
+
interface MediaApiConfig {
|
|
46
|
+
logger?: EngineLogger;
|
|
47
|
+
/** Upload cache adapter (optional, omit to disable caching). */
|
|
48
|
+
uploadCache?: UploadCacheAdapter;
|
|
49
|
+
/** File name sanitizer. */
|
|
50
|
+
sanitizeFileName?: SanitizeFileNameFn;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Small-file media upload module.
|
|
55
|
+
*
|
|
56
|
+
* Handles base64 and URL-based uploads with optional caching and retry.
|
|
57
|
+
*/
|
|
58
|
+
export class MediaApi {
|
|
59
|
+
private readonly client: ApiClient;
|
|
60
|
+
private readonly tokenManager: TokenManager;
|
|
61
|
+
private readonly logger?: EngineLogger;
|
|
62
|
+
private readonly cache?: UploadCacheAdapter;
|
|
63
|
+
private readonly sanitize: SanitizeFileNameFn;
|
|
64
|
+
|
|
65
|
+
constructor(client: ApiClient, tokenManager: TokenManager, config: MediaApiConfig = {}) {
|
|
66
|
+
this.client = client;
|
|
67
|
+
this.tokenManager = tokenManager;
|
|
68
|
+
this.logger = config.logger;
|
|
69
|
+
this.cache = config.uploadCache;
|
|
70
|
+
this.sanitize = config.sanitizeFileName ?? ((n) => n);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Upload media via base64, URL, buffer, or local file path to a C2C or Group target.
|
|
75
|
+
*
|
|
76
|
+
* The `localPath` and `buffer` branches are equivalent to `fileData` for the
|
|
77
|
+
* current one-shot implementation — the file is read and base64-encoded
|
|
78
|
+
* synchronously. They exist as first-class inputs so that a future chunked
|
|
79
|
+
* upload implementation can consume them without interface churn.
|
|
80
|
+
*
|
|
81
|
+
* @param scope - `'c2c'` or `'group'`.
|
|
82
|
+
* @param targetId - User openid or group openid.
|
|
83
|
+
* @param fileType - Media file type code.
|
|
84
|
+
* @param creds - Authentication credentials.
|
|
85
|
+
* @param opts - Upload options. Exactly one of `url`/`fileData`/`buffer`/`localPath`
|
|
86
|
+
* must be supplied.
|
|
87
|
+
* @returns Upload result containing `file_info` for subsequent message sends.
|
|
88
|
+
*/
|
|
89
|
+
async uploadMedia(
|
|
90
|
+
scope: ChatScope,
|
|
91
|
+
targetId: string,
|
|
92
|
+
fileType: MediaFileType,
|
|
93
|
+
creds: { appId: string; clientSecret: string },
|
|
94
|
+
opts: {
|
|
95
|
+
url?: string;
|
|
96
|
+
fileData?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Raw bytes in memory. Currently re-encoded to base64 internally;
|
|
99
|
+
* reserved as a dedicated input for the future chunked uploader.
|
|
100
|
+
*/
|
|
101
|
+
buffer?: Buffer;
|
|
102
|
+
/**
|
|
103
|
+
* On-disk path. Currently read + base64-encoded internally; reserved
|
|
104
|
+
* for streaming ingestion by the future chunked uploader.
|
|
105
|
+
*/
|
|
106
|
+
localPath?: string;
|
|
107
|
+
srvSendMsg?: boolean;
|
|
108
|
+
fileName?: string;
|
|
109
|
+
},
|
|
110
|
+
): Promise<UploadMediaResponse> {
|
|
111
|
+
const sources = [opts.url, opts.fileData, opts.buffer, opts.localPath].filter(
|
|
112
|
+
(v) => v !== undefined,
|
|
113
|
+
);
|
|
114
|
+
if (sources.length === 0) {
|
|
115
|
+
throw new Error(`uploadMedia: one of url/fileData/buffer/localPath is required`);
|
|
116
|
+
}
|
|
117
|
+
if (sources.length > 1) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`uploadMedia: url/fileData/buffer/localPath are mutually exclusive (got ${sources.length})`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// One-shot path: materialize buffer/localPath into fileData.
|
|
124
|
+
// Future chunked-upload work will branch here on size and route
|
|
125
|
+
// buffer/localPath through streaming ingestion instead of base64 encoding.
|
|
126
|
+
let fileData = opts.fileData;
|
|
127
|
+
if (opts.buffer) {
|
|
128
|
+
fileData = opts.buffer.toString("base64");
|
|
129
|
+
} else if (opts.localPath) {
|
|
130
|
+
const buf = await fs.promises.readFile(opts.localPath);
|
|
131
|
+
fileData = buf.toString("base64");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check cache for base64 uploads.
|
|
135
|
+
if (fileData && this.cache) {
|
|
136
|
+
const hash = this.cache.computeHash(fileData);
|
|
137
|
+
const cached = this.cache.get(hash, scope, targetId, fileType);
|
|
138
|
+
if (cached) {
|
|
139
|
+
return { file_uuid: "", file_info: cached, ttl: 0 };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const body: Record<string, unknown> = {
|
|
144
|
+
file_type: fileType,
|
|
145
|
+
srv_send_msg: opts.srvSendMsg ?? false,
|
|
146
|
+
};
|
|
147
|
+
if (opts.url) {
|
|
148
|
+
body.url = opts.url;
|
|
149
|
+
} else if (fileData) {
|
|
150
|
+
body.file_data = fileData;
|
|
151
|
+
}
|
|
152
|
+
if (fileType === MediaFileType.FILE && opts.fileName) {
|
|
153
|
+
body.file_name = this.sanitize(opts.fileName);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
157
|
+
const path = mediaUploadPath(scope, targetId);
|
|
158
|
+
|
|
159
|
+
const result = await withRetry(
|
|
160
|
+
() =>
|
|
161
|
+
this.client.request<UploadMediaResponse>(token, "POST", path, body, {
|
|
162
|
+
redactBodyKeys: ["file_data"],
|
|
163
|
+
uploadRequest: true,
|
|
164
|
+
}),
|
|
165
|
+
UPLOAD_RETRY_POLICY,
|
|
166
|
+
undefined,
|
|
167
|
+
this.logger,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Cache the result for future dedup.
|
|
171
|
+
if (fileData && result.file_info && result.ttl > 0 && this.cache) {
|
|
172
|
+
const hash = this.cache.computeHash(fileData);
|
|
173
|
+
this.cache.set(
|
|
174
|
+
hash,
|
|
175
|
+
scope,
|
|
176
|
+
targetId,
|
|
177
|
+
fileType,
|
|
178
|
+
result.file_info,
|
|
179
|
+
result.file_uuid,
|
|
180
|
+
result.ttl,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Send a media message (upload result → message) to a C2C or Group target.
|
|
189
|
+
*
|
|
190
|
+
* @param scope - `'c2c'` or `'group'`.
|
|
191
|
+
* @param targetId - User openid or group openid.
|
|
192
|
+
* @param fileInfo - `file_info` from a prior upload.
|
|
193
|
+
* @param creds - Authentication credentials.
|
|
194
|
+
* @param opts - Message options.
|
|
195
|
+
*/
|
|
196
|
+
async sendMediaMessage(
|
|
197
|
+
scope: ChatScope,
|
|
198
|
+
targetId: string,
|
|
199
|
+
fileInfo: string,
|
|
200
|
+
creds: { appId: string; clientSecret: string },
|
|
201
|
+
opts?: {
|
|
202
|
+
msgId?: string;
|
|
203
|
+
content?: string;
|
|
204
|
+
},
|
|
205
|
+
): Promise<MessageResponse> {
|
|
206
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
207
|
+
const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1;
|
|
208
|
+
const path = messagePath(scope, targetId);
|
|
209
|
+
|
|
210
|
+
return this.client.request<MessageResponse>(token, "POST", path, {
|
|
211
|
+
msg_type: 7,
|
|
212
|
+
media: { file_info: fileInfo },
|
|
213
|
+
msg_seq: msgSeq,
|
|
214
|
+
...(opts?.content ? { content: opts.content } : {}),
|
|
215
|
+
...(opts?.msgId ? { msg_id: opts.msgId } : {}),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message sending API for the QQ Open Platform.
|
|
3
|
+
*
|
|
4
|
+
* Key design improvements:
|
|
5
|
+
* - Unified `sendMessage(scope, ...)` replaces `sendC2CMessage` + `sendGroupMessage`.
|
|
6
|
+
* - `onMessageSent` hook is scoped to the instance, not a module-level global.
|
|
7
|
+
* - Markdown support flag is per-instance, not a global Map.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
ChatScope,
|
|
12
|
+
MessageResponse,
|
|
13
|
+
OutboundMeta,
|
|
14
|
+
EngineLogger,
|
|
15
|
+
InlineKeyboard,
|
|
16
|
+
StreamMessageRequest,
|
|
17
|
+
} from "../types.js";
|
|
18
|
+
import { formatErrorMessage } from "../utils/format.js";
|
|
19
|
+
import { ApiClient } from "./api-client.js";
|
|
20
|
+
import {
|
|
21
|
+
messagePath,
|
|
22
|
+
channelMessagePath,
|
|
23
|
+
dmMessagePath,
|
|
24
|
+
gatewayPath,
|
|
25
|
+
interactionPath,
|
|
26
|
+
getNextMsgSeq,
|
|
27
|
+
streamMessagePath,
|
|
28
|
+
} from "./routes.js";
|
|
29
|
+
import { TokenManager } from "./token.js";
|
|
30
|
+
|
|
31
|
+
interface MessageApiConfig {
|
|
32
|
+
/** Whether the QQ Bot has markdown permission. */
|
|
33
|
+
markdownSupport: boolean;
|
|
34
|
+
/** Logger for diagnostics. */
|
|
35
|
+
logger?: EngineLogger;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Message sending module.
|
|
42
|
+
*
|
|
43
|
+
* Usage:
|
|
44
|
+
* ```ts
|
|
45
|
+
* const api = new MessageApi(client, tokenMgr, { markdownSupport: true });
|
|
46
|
+
* await api.sendMessage('c2c', openid, 'Hello!', { appId, clientSecret, msgId });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export class MessageApi {
|
|
50
|
+
private readonly client: ApiClient;
|
|
51
|
+
private readonly tokenManager: TokenManager;
|
|
52
|
+
private readonly markdownSupport: boolean;
|
|
53
|
+
private readonly logger?: EngineLogger;
|
|
54
|
+
private messageSentHook: OnMessageSentCallback | null = null;
|
|
55
|
+
|
|
56
|
+
constructor(client: ApiClient, tokenManager: TokenManager, config: MessageApiConfig) {
|
|
57
|
+
this.client = client;
|
|
58
|
+
this.tokenManager = tokenManager;
|
|
59
|
+
this.markdownSupport = config.markdownSupport;
|
|
60
|
+
this.logger = config.logger;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Register a callback invoked when a sent message returns a ref_idx. */
|
|
64
|
+
onMessageSent(callback: OnMessageSentCallback): void {
|
|
65
|
+
this.messageSentHook = callback;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Notify the registered hook about a sent message.
|
|
70
|
+
* Use this for media sends that bypass `sendAndNotify`.
|
|
71
|
+
*/
|
|
72
|
+
notifyMessageSent(refIdx: string, meta: OutboundMeta): void {
|
|
73
|
+
if (this.messageSentHook) {
|
|
74
|
+
try {
|
|
75
|
+
this.messageSentHook(refIdx, meta);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
this.logger?.error?.(
|
|
78
|
+
`[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- Unified message sending ----
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Send a text message to a C2C or Group target.
|
|
88
|
+
*
|
|
89
|
+
* Automatically constructs the correct path, body format (markdown vs plain),
|
|
90
|
+
* and message sequence number.
|
|
91
|
+
*/
|
|
92
|
+
async sendMessage(
|
|
93
|
+
scope: ChatScope,
|
|
94
|
+
targetId: string,
|
|
95
|
+
content: string,
|
|
96
|
+
creds: Credentials,
|
|
97
|
+
opts?: {
|
|
98
|
+
msgId?: string;
|
|
99
|
+
messageReference?: string;
|
|
100
|
+
inlineKeyboard?: InlineKeyboard;
|
|
101
|
+
},
|
|
102
|
+
): Promise<MessageResponse> {
|
|
103
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
104
|
+
const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1;
|
|
105
|
+
const body = this.buildMessageBody(
|
|
106
|
+
content,
|
|
107
|
+
opts?.msgId,
|
|
108
|
+
msgSeq,
|
|
109
|
+
opts?.messageReference,
|
|
110
|
+
opts?.inlineKeyboard,
|
|
111
|
+
);
|
|
112
|
+
const path = messagePath(scope, targetId);
|
|
113
|
+
return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Send a proactive (no msgId) message to a C2C or Group target. */
|
|
117
|
+
async sendProactiveMessage(
|
|
118
|
+
scope: ChatScope,
|
|
119
|
+
targetId: string,
|
|
120
|
+
content: string,
|
|
121
|
+
creds: Credentials,
|
|
122
|
+
): Promise<MessageResponse> {
|
|
123
|
+
if (!content?.trim()) {
|
|
124
|
+
throw new Error("Proactive message content must not be empty");
|
|
125
|
+
}
|
|
126
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
127
|
+
const body = this.buildProactiveBody(content);
|
|
128
|
+
const path = messagePath(scope, targetId);
|
|
129
|
+
return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---- Channel / DM ----
|
|
133
|
+
|
|
134
|
+
/** Send a channel message. */
|
|
135
|
+
async sendChannelMessage(opts: {
|
|
136
|
+
channelId: string;
|
|
137
|
+
content: string;
|
|
138
|
+
creds: Credentials;
|
|
139
|
+
msgId?: string;
|
|
140
|
+
}): Promise<MessageResponse> {
|
|
141
|
+
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
|
142
|
+
return this.client.request<MessageResponse>(token, "POST", channelMessagePath(opts.channelId), {
|
|
143
|
+
content: opts.content,
|
|
144
|
+
...(opts.msgId ? { msg_id: opts.msgId } : {}),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Send a DM (guild direct message). */
|
|
149
|
+
async sendDmMessage(opts: {
|
|
150
|
+
guildId: string;
|
|
151
|
+
content: string;
|
|
152
|
+
creds: Credentials;
|
|
153
|
+
msgId?: string;
|
|
154
|
+
}): Promise<MessageResponse> {
|
|
155
|
+
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
|
156
|
+
return this.client.request<MessageResponse>(token, "POST", dmMessagePath(opts.guildId), {
|
|
157
|
+
content: opts.content,
|
|
158
|
+
...(opts.msgId ? { msg_id: opts.msgId } : {}),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- C2C Input Notify ----
|
|
163
|
+
|
|
164
|
+
/** Send a typing indicator to a C2C user. */
|
|
165
|
+
async sendInputNotify(opts: {
|
|
166
|
+
openid: string;
|
|
167
|
+
creds: Credentials;
|
|
168
|
+
msgId?: string;
|
|
169
|
+
inputSecond?: number;
|
|
170
|
+
}): Promise<{ refIdx?: string }> {
|
|
171
|
+
const inputSecond = opts.inputSecond ?? 60;
|
|
172
|
+
const token = await this.tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
|
|
173
|
+
const msgSeq = opts.msgId ? getNextMsgSeq(opts.msgId) : 1;
|
|
174
|
+
const response = await this.client.request<{ ext_info?: { ref_idx?: string } }>(
|
|
175
|
+
token,
|
|
176
|
+
"POST",
|
|
177
|
+
messagePath("c2c", opts.openid),
|
|
178
|
+
{
|
|
179
|
+
msg_type: 6,
|
|
180
|
+
input_notify: { input_type: 1, input_second: inputSecond },
|
|
181
|
+
msg_seq: msgSeq,
|
|
182
|
+
...(opts.msgId ? { msg_id: opts.msgId } : {}),
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
return { refIdx: response.ext_info?.ref_idx };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- Interaction ----
|
|
189
|
+
|
|
190
|
+
/** Acknowledge an INTERACTION_CREATE event. */
|
|
191
|
+
async acknowledgeInteraction(
|
|
192
|
+
interactionId: string,
|
|
193
|
+
creds: Credentials,
|
|
194
|
+
code: 0 | 1 | 2 | 3 | 4 | 5 = 0,
|
|
195
|
+
): Promise<void> {
|
|
196
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
197
|
+
await this.client.request(token, "PUT", interactionPath(interactionId), { code });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- Gateway ----
|
|
201
|
+
|
|
202
|
+
/** Get the WebSocket gateway URL. */
|
|
203
|
+
async getGatewayUrl(creds: Credentials): Promise<string> {
|
|
204
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
205
|
+
const data = await this.client.request<{ url: string }>(token, "GET", gatewayPath());
|
|
206
|
+
return data.url;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Send a C2C stream message chunk (`/v2/users/{openid}/stream_messages`).
|
|
211
|
+
* Only supported for one-to-one chats.
|
|
212
|
+
*/
|
|
213
|
+
async sendC2CStreamMessage(
|
|
214
|
+
creds: Credentials,
|
|
215
|
+
openid: string,
|
|
216
|
+
req: StreamMessageRequest,
|
|
217
|
+
): Promise<MessageResponse> {
|
|
218
|
+
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
|
219
|
+
const path = streamMessagePath(openid);
|
|
220
|
+
const body: Record<string, unknown> = {
|
|
221
|
+
input_mode: req.input_mode,
|
|
222
|
+
input_state: req.input_state,
|
|
223
|
+
content_type: req.content_type,
|
|
224
|
+
content_raw: req.content_raw,
|
|
225
|
+
event_id: req.event_id,
|
|
226
|
+
msg_id: req.msg_id,
|
|
227
|
+
msg_seq: req.msg_seq,
|
|
228
|
+
index: req.index,
|
|
229
|
+
};
|
|
230
|
+
if (req.stream_msg_id) {
|
|
231
|
+
body.stream_msg_id = req.stream_msg_id;
|
|
232
|
+
}
|
|
233
|
+
return this.client.request<MessageResponse>(token, "POST", path, body);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- Internal ----
|
|
237
|
+
|
|
238
|
+
private async sendAndNotify(
|
|
239
|
+
_appId: string,
|
|
240
|
+
accessToken: string,
|
|
241
|
+
method: string,
|
|
242
|
+
path: string,
|
|
243
|
+
body: unknown,
|
|
244
|
+
meta: OutboundMeta,
|
|
245
|
+
): Promise<MessageResponse> {
|
|
246
|
+
const result = await this.client.request<MessageResponse>(accessToken, method, path, body);
|
|
247
|
+
if (result.ext_info?.ref_idx && this.messageSentHook) {
|
|
248
|
+
try {
|
|
249
|
+
this.messageSentHook(result.ext_info.ref_idx, meta);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
this.logger?.error?.(
|
|
252
|
+
`[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private buildMessageBody(
|
|
260
|
+
content: string,
|
|
261
|
+
msgId: string | undefined,
|
|
262
|
+
msgSeq: number,
|
|
263
|
+
messageReference?: string,
|
|
264
|
+
inlineKeyboard?: InlineKeyboard,
|
|
265
|
+
): Record<string, unknown> {
|
|
266
|
+
const body: Record<string, unknown> = this.markdownSupport
|
|
267
|
+
? { markdown: { content }, msg_type: 2, msg_seq: msgSeq }
|
|
268
|
+
: { content, msg_type: 0, msg_seq: msgSeq };
|
|
269
|
+
|
|
270
|
+
if (msgId) {
|
|
271
|
+
body.msg_id = msgId;
|
|
272
|
+
}
|
|
273
|
+
if (messageReference && !this.markdownSupport) {
|
|
274
|
+
body.message_reference = { message_id: messageReference };
|
|
275
|
+
}
|
|
276
|
+
if (inlineKeyboard) {
|
|
277
|
+
body.keyboard = inlineKeyboard;
|
|
278
|
+
}
|
|
279
|
+
return body;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private buildProactiveBody(content: string): Record<string, unknown> {
|
|
283
|
+
return this.markdownSupport ? { markdown: { content }, msg_type: 2 } : { content, msg_type: 0 };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- Shared helpers ----
|
|
288
|
+
|
|
289
|
+
/** Credentials needed to authenticate API requests. */
|
|
290
|
+
export interface Credentials {
|
|
291
|
+
appId: string;
|
|
292
|
+
clientSecret: string;
|
|
293
|
+
}
|