/**
* Message sending API for the QQ Open Platform .
*
* Key design improvements :
* - Unified ` sendMessage ( scope , . . . ) ` replaces ` sendC2CMessage ` + ` sendGroupMessage ` .
* - ` onMessageSent ` hook is scoped to the instance , not a module - level global .
* - Markdown support flag is per - instance , not a global Map .
*/
import type {
ChatScope,
MessageResponse,
OutboundMeta,
EngineLogger,
InlineKeyboard,
} from "../types.js" ;
import { formatErrorMessage } from "../utils/format.js" ;
import { ApiClient } from "./api-client.js" ;
import {
messagePath,
channelMessagePath,
dmMessagePath,
gatewayPath,
interactionPath,
getNextMsgSeq,
} from "./routes.js" ;
import { TokenManager } from "./token.js" ;
export interface MessageApiConfig {
/** Whether the QQ Bot has markdown permission. */
markdownSupport: boolean ;
/** Logger for diagnostics. */
logger?: EngineLogger;
}
type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void ;
/**
* Message sending module .
*
* Usage :
* ` ` ` ts
* const api = new MessageApi ( client , tokenMgr , { markdownSupport : true } ) ;
* await api . sendMessage ( ' c2c ' , openid , ' Hello ! ' , { appId , clientSecret , msgId } ) ;
* ` ` `
*/
export class MessageApi {
private readonly client: ApiClient;
private readonly tokenManager: TokenManager;
private readonly markdownSupport: boolean ;
private readonly logger?: EngineLogger;
private messageSentHook: OnMessageSentCallback | null = null ;
constructor(client: ApiClient, tokenManager: TokenManager, config: MessageApiConfig) {
this .client = client;
this .tokenManager = tokenManager;
this .markdownSupport = config.markdownSupport;
this .logger = config.logger;
}
/** Register a callback invoked when a sent message returns a ref_idx. */
onMessageSent(callback: OnMessageSentCallback): void {
this .messageSentHook = callback;
}
/**
* Notify the registered hook about a sent message .
* Use this for media sends that bypass ` sendAndNotify ` .
*/
notifyMessageSent(refIdx: string, meta: OutboundMeta): void {
if (this .messageSentHook) {
try {
this .messageSentHook(refIdx, meta);
} catch (err) {
this .logger?.error?.(
`[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`,
);
}
}
}
// ---- Unified message sending ----
/**
* Send a text message to a C2C or Group target .
*
* Automatically constructs the correct path , body format ( markdown vs plain ) ,
* and message sequence number .
*/
async sendMessage(
scope: ChatScope,
targetId: string,
content: string,
creds: Credentials,
opts?: {
msgId?: string;
messageReference?: string;
inlineKeyboard?: InlineKeyboard;
},
): Promise<MessageResponse> {
const token = await this .tokenManager.getAccessToken(creds.appId, creds.clientSecret);
const msgSeq = opts?.msgId ? getNextMsgSeq(opts.msgId) : 1 ;
const body = this .buildMessageBody(
content,
opts?.msgId,
msgSeq,
opts?.messageReference,
opts?.inlineKeyboard,
);
const path = messagePath(scope, targetId);
return this .sendAndNotify(creds.appId, token, "POST" , path, body, { text: content });
}
/** Send a proactive (no msgId) message to a C2C or Group target. */
async sendProactiveMessage(
scope: ChatScope,
targetId: string,
content: string,
creds: Credentials,
): Promise<MessageResponse> {
if (!content?.trim()) {
throw new Error("Proactive message content must not be empty" );
}
const token = await this .tokenManager.getAccessToken(creds.appId, creds.clientSecret);
const body = this .buildProactiveBody(content);
const path = messagePath(scope, targetId);
return this .sendAndNotify(creds.appId, token, "POST" , path, body, { text: content });
}
// ---- Channel / DM ----
/** Send a channel message. */
async sendChannelMessage(opts: {
channelId: string;
content: string;
creds: Credentials;
msgId?: string;
}): Promise<MessageResponse> {
const token = await this .tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
return this .client.request<MessageResponse>(token, "POST" , channelMessagePath(opts.channelId), {
content: opts.content,
...(opts.msgId ? { msg_id: opts.msgId } : {}),
});
}
/** Send a DM (guild direct message). */
async sendDmMessage(opts: {
guildId: string;
content: string;
creds: Credentials;
msgId?: string;
}): Promise<MessageResponse> {
const token = await this .tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
return this .client.request<MessageResponse>(token, "POST" , dmMessagePath(opts.guildId), {
content: opts.content,
...(opts.msgId ? { msg_id: opts.msgId } : {}),
});
}
// ---- C2C Input Notify ----
/** Send a typing indicator to a C2C user. */
async sendInputNotify(opts: {
openid: string;
creds: Credentials;
msgId?: string;
inputSecond?: number;
}): Promise<{ refIdx?: string }> {
const inputSecond = opts.inputSecond ?? 60 ;
const token = await this .tokenManager.getAccessToken(opts.creds.appId, opts.creds.clientSecret);
const msgSeq = opts.msgId ? getNextMsgSeq(opts.msgId) : 1 ;
const response = await this .client.request<{ ext_info?: { ref_idx?: string } }>(
token,
"POST" ,
messagePath("c2c" , opts.openid),
{
msg_type: 6 ,
input_notify: { input_type: 1 , input_second: inputSecond },
msg_seq: msgSeq,
...(opts.msgId ? { msg_id: opts.msgId } : {}),
},
);
return { refIdx: response.ext_info?.ref_idx };
}
// ---- Interaction ----
/** Acknowledge an INTERACTION_CREATE event. */
async acknowledgeInteraction(
interactionId: string,
creds: Credentials,
code: 0 | 1 | 2 | 3 | 4 | 5 = 0 ,
): Promise<void > {
const token = await this .tokenManager.getAccessToken(creds.appId, creds.clientSecret);
await this .client.request(token, "PUT" , interactionPath(interactionId), { code });
}
// ---- Gateway ----
/** Get the WebSocket gateway URL. */
async getGatewayUrl(creds: Credentials): Promise<string> {
const token = await this .tokenManager.getAccessToken(creds.appId, creds.clientSecret);
const data = await this .client.request<{ url: string }>(token, "GET" , gatewayPath());
return data.url;
}
// ---- Internal ----
private async sendAndNotify(
appId: string,
accessToken: string,
method: string,
path: string,
body: unknown,
meta: OutboundMeta,
): Promise<MessageResponse> {
const result = await this .client.request<MessageResponse>(accessToken, method, path, body);
if (result.ext_info?.ref_idx && this .messageSentHook) {
try {
this .messageSentHook(result.ext_info.ref_idx, meta);
} catch (err) {
this .logger?.error?.(
`[qqbot:messages] onMessageSent hook error: ${formatErrorMessage(err)}`,
);
}
}
return result;
}
private buildMessageBody(
content: string,
msgId: string | undefined,
msgSeq: number,
messageReference?: string,
inlineKeyboard?: InlineKeyboard,
): Record<string, unknown> {
const body: Record<string, unknown> = this .markdownSupport
? { markdown: { content }, msg_type: 2 , msg_seq: msgSeq }
: { content, msg_type: 0 , msg_seq: msgSeq };
if (msgId) {
body.msg_id = msgId;
}
if (messageReference && !this .markdownSupport) {
body.message_reference = { message_id: messageReference };
}
if (inlineKeyboard) {
body.keyboard = inlineKeyboard;
}
return body;
}
private buildProactiveBody(content: string): Record<string, unknown> {
return this .markdownSupport ? { markdown: { content }, msg_type: 2 } : { content, msg_type: 0 };
}
}
// ---- Shared helpers ----
/** Credentials needed to authenticate API requests. */
export interface Credentials {
appId: string;
clientSecret: string;
}
// Re-export getNextMsgSeq for consumers that import from messages.ts.
export { getNextMsgSeq } from "./routes.js" ;
Messung V0.5 in Prozent C=92 H=100 G=95
¤ Dauer der Verarbeitung: 0.8 Sekunden
(vorverarbeitet am 2026-09-29)
¤
*© Formatika GbR, Deutschland