Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 


Quelle  sender.ts

  Sprache: JAVA
 

Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]

/**
 * Unified message sender — per-account resource management + business function layer.
 *
 * This module is the **single entry point** for all QQ Bot API operations.
 *
 * ## Architecture
 *
 * Each account gets its own isolated resource stack:
 *
 * ```
 * _accountRegistry: Map<appId, AccountContext>
 *
 * AccountContext {
 *   logger      — per-account prefixed logger
 *   client      — per-account ApiClient
 *   tokenMgr    — per-account TokenManager
 *   mediaApi    — per-account MediaApi
 *   messageApi  — per-account MessageApi
 * }
 * ```
 *
 * Upper-layer callers (gateway, outbound, reply-dispatcher, proactive)
 * always go through exported functions that resolve the correct
 * `AccountContext` by appId.
 */

import os from "node:os";
import { ApiClient } from "../api/api-client.js";
import { MediaApi as MediaApiClass } from "../api/media.js";
import type { Credentials } from "../api/messages.js";
import { MessageApi as MessageApiClass } from "../api/messages.js";
import { getNextMsgSeq } from "../api/routes.js";
import { TokenManager } from "../api/token.js";
import {
  MediaFileType,
  type ChatScope,
  type EngineLogger,
  type MessageResponse,
  type OutboundMeta,
} from "../types.js";
import { formatErrorMessage } from "../utils/format.js";
import { debugLog, debugError, debugWarn } from "../utils/log.js";
import { sanitizeFileName } from "../utils/string-normalize.js";
import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "../utils/upload-cache.js";

// ============ Re-exported types ============

export { ApiError } from "../types.js";
export type { OutboundMeta, MessageResponse, UploadMediaResponse } from "../types.js";
export { MediaFileType } from "../types.js";

// ============ Plugin User-Agent ============

let _pluginVersion = "unknown";
let _openclawVersion = "unknown";

/** Build the User-Agent string from the current plugin and framework versions. */
function buildUserAgent(): string {
  return `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()}; OpenClaw/${_openclawVersion})`;
}

/** Return the current User-Agent string. */
export function getPluginUserAgent(): string {
  return buildUserAgent();
}

/**
 * Initialize sender with the plugin version.
 * Must be called once during startup before any API calls.
 */
export function initSender(options: { pluginVersion?: string; openclawVersion?: string }): void {
  if (options.pluginVersion) {
    _pluginVersion = options.pluginVersion;
  }
  if (options.openclawVersion) {
    _openclawVersion = options.openclawVersion;
  }
}

/** Update the OpenClaw framework version in the User-Agent (called after runtime injection). */
export function setOpenClawVersion(version: string): void {
  if (version) {
    _openclawVersion = version;
  }
}

// ============ Per-account resource management ============

/** Complete resource context for a single account. */
interface AccountContext {
  logger: EngineLogger;
  client: ApiClient;
  tokenMgr: TokenManager;
  mediaApi: MediaApiClass;
  messageApi: MessageApiClass;
  markdownSupport: boolean;
}

/** Per-appId account registry — each account owns all its resources. */
const _accountRegistry = new Map<string, AccountContext>();

/** Fallback logger for unregistered accounts (CLI / test scenarios). */
const _fallbackLogger: EngineLogger = {
  info: (msg: string) => debugLog(msg),
  error: (msg: string) => debugError(msg),
  warn: (msg: string) => debugWarn(msg),
  debug: (msg: string) => debugLog(msg),
};

/**
 * Build a full resource stack for a given logger.
 *
 * Shared by both `registerAccount` (explicit registration) and
 * `resolveAccount` (lazy fallback for unregistered accounts).
 */
function buildAccountContext(logger: EngineLogger, markdownSupport: boolean): AccountContext {
  const client = new ApiClient({ logger, userAgent: buildUserAgent });
  const tokenMgr = new TokenManager({ logger, userAgent: buildUserAgent });
  const mediaApi = new MediaApiClass(client, tokenMgr, {
    logger,
    uploadCache: {
      computeHash: computeFileHash,
      get: (hash: string, scope: string, targetId: string, fileType: number) =>
        getCachedFileInfo(hash, scope as ChatScope, targetId, fileType),
      set: (
        hash: string,
        scope: string,
        targetId: string,
        fileType: number,
        fileInfo: string,
        fileUuid: string,
        ttl: number,
      ) => setCachedFileInfo(hash, scope as ChatScope, targetId, fileType, fileInfo, fileUuid, ttl),
    },
    sanitizeFileName,
  });
  const messageApi = new MessageApiClass(client, tokenMgr, {
    markdownSupport,
    logger,
  });

  return { logger, client, tokenMgr, mediaApi, messageApi, markdownSupport };
}

/**
 * Register an account — atomically sets up all per-appId resources.
 *
 * Must be called once per account during gateway startup.
 * Creates a complete isolated resource stack (ApiClient, TokenManager,
 * MediaApi, MessageApi) with the per-account logger.
 */
export function registerAccount(
  appId: string,
  options: {
    logger: EngineLogger;
    markdownSupport?: boolean;
  },
): void {
  const key = appId.trim();
  const md = options.markdownSupport === true;
  _accountRegistry.set(key, buildAccountContext(options.logger, md));
}

/**
 * Initialize per-app API behavior such as markdown support.
 *
 * If the account was already registered via `registerAccount()`, updates its
 * MessageApi with the new markdown setting while preserving the existing
 * logger and resource stack. Otherwise creates a new context.
 */
export function initApiConfig(appId: string, options: { markdownSupport?: boolean }): void {
  const key = appId.trim();
  const md = options.markdownSupport === true;
  const existing = _accountRegistry.get(key);
  if (existing) {
    // Re-create only MessageApi with updated config, reuse existing stack.
    existing.messageApi = new MessageApiClass(existing.client, existing.tokenMgr, {
      markdownSupport: md,
      logger: existing.logger,
    });
    existing.markdownSupport = md;
  } else {
    _accountRegistry.set(key, buildAccountContext(_fallbackLogger, md));
  }
}

/**
 * Resolve the AccountContext for a given appId.
 *
 * If the account was registered via `registerAccount()`, returns the
 * pre-built context. Otherwise lazily creates a fallback context.
 */
function resolveAccount(appId: string): AccountContext {
  const key = appId.trim();
  let ctx = _accountRegistry.get(key);
  if (!ctx) {
    ctx = buildAccountContext(_fallbackLogger, false);
    _accountRegistry.set(key, ctx);
  }
  return ctx;
}

// ============ Instance getters (for advanced callers) ============

/** Get the MessageApi instance for the given appId. */
export function getMessageApi(appId: string): MessageApiClass {
  return resolveAccount(appId).messageApi;
}

/** Get the MediaApi instance for the given appId. */
export function getMediaApi(appId: string): MediaApiClass {
  return resolveAccount(appId).mediaApi;
}

/** Get the TokenManager instance for the given appId. */
export function getTokenManager(appId: string): TokenManager {
  return resolveAccount(appId).tokenMgr;
}

/** Get the ApiClient instance for the given appId. */
export function getApiClient(appId: string): ApiClient {
  return resolveAccount(appId).client;
}

// ============ Per-appId config ============

type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void;

/** Register an outbound-message hook scoped to one appId. */
export function onMessageSent(appId: string, callback: OnMessageSentCallback): void {
  resolveAccount(appId).messageApi.onMessageSent(callback);
}

/** Return whether markdown is enabled for the given appId. */
export function isMarkdownSupport(appId: string): boolean {
  return _accountRegistry.get(appId.trim())?.markdownSupport ?? false;
}

// ============ Token management ============

export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
  return resolveAccount(appId).tokenMgr.getAccessToken(appId, clientSecret);
}

export function clearTokenCache(appId?: string): void {
  if (appId) {
    resolveAccount(appId).tokenMgr.clearCache(appId);
  } else {
    for (const ctx of _accountRegistry.values()) {
      ctx.tokenMgr.clearCache();
    }
  }
}

export function getTokenStatus(appId: string): {
  status: "valid" | "expired" | "refreshing" | "none";
  expiresAt: number | null;
} {
  return resolveAccount(appId).tokenMgr.getStatus(appId);
}

export function startBackgroundTokenRefresh(
  appId: string,
  clientSecret: string,
  options?: {
    refreshAheadMs?: number;
    randomOffsetMs?: number;
    minRefreshIntervalMs?: number;
    retryDelayMs?: number;
    log?: {
      info: (msg: string) => void;
      error: (msg: string) => void;
      debug?: (msg: string) => void;
    };
  },
): void {
  resolveAccount(appId).tokenMgr.startBackgroundRefresh(appId, clientSecret, options);
}

export function stopBackgroundTokenRefresh(appId?: string): void {
  if (appId) {
    resolveAccount(appId).tokenMgr.stopBackgroundRefresh(appId);
  } else {
    for (const ctx of _accountRegistry.values()) {
      ctx.tokenMgr.stopBackgroundRefresh();
    }
  }
}

export function isBackgroundTokenRefreshRunning(appId?: string): boolean {
  if (appId) {
    return resolveAccount(appId).tokenMgr.isBackgroundRefreshRunning(appId);
  }
  for (const ctx of _accountRegistry.values()) {
    if (ctx.tokenMgr.isBackgroundRefreshRunning()) {
      return true;
    }
  }
  return false;
}

// ============ Gateway URL ============

export async function getGatewayUrl(accessToken: string, appId: string): Promise<string> {
  const data = await resolveAccount(appId).client.request<{ url: string }>(
    accessToken,
    "GET",
    "/gateway",
  );
  return data.url;
}

// ============ Interaction ============

/** Acknowledge an INTERACTION_CREATE event via PUT /interactions/{id}. */
export async function acknowledgeInteraction(
  creds: AccountCreds,
  interactionId: string,
  code: 0 | 1 | 2 | 3 | 4 | 5 = 0,
): Promise<void> {
  const ctx = resolveAccount(creds.appId);
  const token = await ctx.tokenMgr.getAccessToken(creds.appId, creds.clientSecret);
  await ctx.client.request(token, "PUT", `/interactions/${interactionId}`, { code });
}

// ============ Types ============

/** Delivery target resolved from event context. */
export interface DeliveryTarget {
  type: "c2c" | "group" | "channel" | "dm";
  id: string;
}

/** Account credentials for API authentication. */
export interface AccountCreds {
  appId: string;
  clientSecret: string;
}

// ============ Token retry ============

/**
 * Execute an API call with automatic token-retry on 401 errors.
 */
export async function withTokenRetry<T>(
  creds: AccountCreds,
  sendFn: (token: string) => Promise<T>,
  log?: EngineLogger,
  _accountId?: string,
): Promise<T> {
  try {
    const token = await getAccessToken(creds.appId, creds.clientSecret);
    return await sendFn(token);
  } catch (err) {
    const errMsg = formatErrorMessage(err);
    if (errMsg.includes("401") || errMsg.includes("token") || errMsg.includes("access_token")) {
      log?.debug?.(`Token may be expired, refreshing...`);
      clearTokenCache(creds.appId);
      const newToken = await getAccessToken(creds.appId, creds.clientSecret);
      return await sendFn(newToken);
    }
    throw err;
  }
}

// ============ Media hook helper ============

/**
 * Notify the MessageApi onMessageSent hook after a media send.
 */
function notifyMediaHook(appId: string, result: MessageResponse, meta: OutboundMeta): void {
  const refIdx = result.ext_info?.ref_idx;
  if (refIdx) {
    resolveAccount(appId).messageApi.notifyMessageSent(refIdx, meta);
  }
}

// ============ Text sending ============

/**
 * Send a text message to any QQ target type.
 *
 * Automatically routes to the correct API method based on target type.
 * Handles passive (with msgId) and proactive (without msgId) modes.
 */
export async function sendText(
  target: DeliveryTarget,
  content: string,
  creds: AccountCreds,
  opts?: { msgId?: string; messageReference?: string },
): Promise<MessageResponse> {
  const api = resolveAccount(creds.appId).messageApi;
  const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };

  if (target.type === "c2c" || target.type === "group") {
    const scope: ChatScope = target.type;
    if (opts?.msgId) {
      return api.sendMessage(scope, target.id, content, c, {
        msgId: opts.msgId,
        messageReference: opts.messageReference,
      });
    }
    return api.sendProactiveMessage(scope, target.id, content, c);
  }

  if (target.type === "dm") {
    return api.sendDmMessage({ guildId: target.id, content, creds: c, msgId: opts?.msgId });
  }

  return api.sendChannelMessage({ channelId: target.id, content, creds: c, msgId: opts?.msgId });
}

/**
 * Send text with automatic token-retry.
 */
export async function sendTextWithRetry(
  target: DeliveryTarget,
  content: string,
  creds: AccountCreds,
  opts?: { msgId?: string; messageReference?: string },
  log?: EngineLogger,
): Promise<MessageResponse> {
  return withTokenRetry(
    creds,
    async () => sendText(target, content, creds, opts),
    log,
    creds.appId,
  );
}

/**
 * Send a proactive text message (no msgId).
 */
export async function sendProactiveText(
  target: DeliveryTarget,
  content: string,
  creds: AccountCreds,
): Promise<MessageResponse> {
  return sendText(target, content, creds);
}

// ============ Input notify ============

/**
 * Send a typing indicator to a C2C user.
 */
export async function sendInputNotify(opts: {
  openid: string;
  creds: AccountCreds;
  msgId?: string;
  inputSecond?: number;
}): Promise<{ refIdx?: string }> {
  const api = resolveAccount(opts.creds.appId).messageApi;
  const c: Credentials = { appId: opts.creds.appId, clientSecret: opts.creds.clientSecret };
  return api.sendInputNotify({
    openid: opts.openid,
    creds: c,
    msgId: opts.msgId,
    inputSecond: opts.inputSecond,
  });
}

/**
 * Raw-token input notify — compatible with TypingKeepAlive's callback signature.
 */
export function createRawInputNotifyFn(
  appId: string,
): (
  token: string,
  openid: string,
  msgId: string | undefined,
  inputSecond: number,
) => Promise<unknown> {
  return async (token, openid, msgId, inputSecond) => {
    const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
    return resolveAccount(appId).client.request(token, "POST", `/v2/users/${openid}/messages`, {
      msg_type: 6,
      input_notify: { input_type: 1, input_second: inputSecond },
      msg_seq: msgSeq,
      ...(msgId ? { msg_id: msgId } : {}),
    });
  };
}

// ============ Image sending ============

/**
 * Upload and send an image message to any C2C/Group target.
 */
export async function sendImage(
  target: DeliveryTarget,
  imageUrl: string,
  creds: AccountCreds,
  opts?: { msgId?: string; content?: string; localPath?: string },
): Promise<MessageResponse> {
  if (target.type !== "c2c" && target.type !== "group") {
    throw new Error(`Image sending not supported for target type: ${target.type}`);
  }

  const ctx = resolveAccount(creds.appId);
  const scope: ChatScope = target.type;
  const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };

  const isBase64 = imageUrl.startsWith("data:");
  let uploadOpts: { url?: string; fileData?: string };
  if (isBase64) {
    const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
    if (!matches) {
      throw new Error("Invalid Base64 Data URL format");
    }
    uploadOpts = { fileData: matches[2] };
  } else {
    uploadOpts = { url: imageUrl };
  }

  const uploadResult = await ctx.mediaApi.uploadMedia(
    scope,
    target.id,
    MediaFileType.IMAGE,
    c,
    uploadOpts,
  );

  const meta: OutboundMeta = {
    text: opts?.content,
    mediaType: "image",
    ...(!isBase64 ? { mediaUrl: imageUrl } : {}),
    ...(opts?.localPath ? { mediaLocalPath: opts.localPath } : {}),
  };

  const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, {
    msgId: opts?.msgId,
    content: opts?.content,
  });

  notifyMediaHook(creds.appId, result, meta);

  return result;
}

// ============ Voice sending ============

/**
 * Upload and send a voice message.
 */
export async function sendVoiceMessage(
  target: DeliveryTarget,
  creds: AccountCreds,
  opts: {
    voiceBase64?: string;
    voiceUrl?: string;
    msgId?: string;
    ttsText?: string;
    filePath?: string;
  },
): Promise<MessageResponse> {
  if (target.type !== "c2c" && target.type !== "group") {
    throw new Error(`Voice sending not supported for target type: ${target.type}`);
  }

  const ctx = resolveAccount(creds.appId);
  const scope: ChatScope = target.type;
  const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };

  const uploadResult = await ctx.mediaApi.uploadMedia(scope, target.id, MediaFileType.VOICE, c, {
    url: opts.voiceUrl,
    fileData: opts.voiceBase64,
  });

  const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, {
    msgId: opts.msgId,
  });

  notifyMediaHook(creds.appId, result, {
    mediaType: "voice",
    ...(opts.ttsText ? { ttsText: opts.ttsText } : {}),
    ...(opts.filePath ? { mediaLocalPath: opts.filePath } : {}),
  });

  return result;
}

// ============ Video sending ============

/**
 * Upload and send a video message.
 */
export async function sendVideoMessage(
  target: DeliveryTarget,
  creds: AccountCreds,
  opts: {
    videoUrl?: string;
    videoBase64?: string;
    msgId?: string;
    content?: string;
    localPath?: string;
  },
): Promise<MessageResponse> {
  if (target.type !== "c2c" && target.type !== "group") {
    throw new Error(`Video sending not supported for target type: ${target.type}`);
  }

  const ctx = resolveAccount(creds.appId);
  const scope: ChatScope = target.type;
  const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };

  const uploadResult = await ctx.mediaApi.uploadMedia(scope, target.id, MediaFileType.VIDEO, c, {
    url: opts.videoUrl,
    fileData: opts.videoBase64,
  });

  const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, {
    msgId: opts.msgId,
    content: opts.content,
  });

  notifyMediaHook(creds.appId, result, {
    text: opts.content,
    mediaType: "video",
    ...(opts.videoUrl ? { mediaUrl: opts.videoUrl } : {}),
    ...(opts.localPath ? { mediaLocalPath: opts.localPath } : {}),
  });

  return result;
}

// ============ File sending ============

/**
 * Upload and send a file message.
 */
export async function sendFileMessage(
  target: DeliveryTarget,
  creds: AccountCreds,
  opts: {
    fileBase64?: string;
    fileUrl?: string;
    msgId?: string;
    fileName?: string;
    localFilePath?: string;
  },
): Promise<MessageResponse> {
  if (target.type !== "c2c" && target.type !== "group") {
    throw new Error(`File sending not supported for target type: ${target.type}`);
  }

  const ctx = resolveAccount(creds.appId);
  const scope: ChatScope = target.type;
  const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };

  const uploadResult = await ctx.mediaApi.uploadMedia(scope, target.id, MediaFileType.FILE, c, {
    url: opts.fileUrl,
    fileData: opts.fileBase64,
    fileName: opts.fileName,
  });

  const result = await ctx.mediaApi.sendMediaMessage(scope, target.id, uploadResult.file_info, c, {
    msgId: opts.msgId,
  });

  notifyMediaHook(creds.appId, result, {
    mediaType: "file",
    mediaUrl: opts.fileUrl,
    mediaLocalPath: opts.localFilePath ?? opts.fileName,
  });

  return result;
}

// ============ Helpers ============

/** Build a DeliveryTarget from event context fields. */
export function buildDeliveryTarget(event: {
  type: "c2c" | "guild" | "dm" | "group";
  senderId: string;
  channelId?: string;
  guildId?: string;
  groupOpenid?: string;
}): DeliveryTarget {
  switch (event.type) {
    case "c2c":
      return { type: "c2c", id: event.senderId };
    case "group":
      return { type: "group", id: event.groupOpenid! };
    case "dm":
      return { type: "dm", id: event.guildId! };
    default:
      return { type: "channel", id: event.channelId! };
  }
}

/** Build AccountCreds from a GatewayAccount. */
export function accountToCreds(account: { appId: string; clientSecret: string }): AccountCreds {
  return { appId: account.appId, clientSecret: account.clientSecret };
}

/** Check whether a target type supports rich media (C2C and Group only). */
export function supportsRichMedia(targetType: string): boolean {
  return targetType === "c2c" || targetType === "group";
}

¤ Dauer der Verarbeitung: 0.1 Sekunden  (vorverarbeitet am  2026-04-27) ¤

*© Formatika GbR, Deutschland






Wurzel

Suchen

Beweissystem der NASA

Beweissystem Isabelle

NIST Cobol Testsuite

Cephes Mathematical Library

Wiener Entwicklungsmethode

Haftungshinweis

Die Informationen auf dieser Webseite wurden nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit, noch Qualität der bereit gestellten Informationen zugesichert.

Bemerkung:

Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.






                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

Software

     Produkte
     Quellcodebibliothek

Aktivitäten

     Artikel über Sicherheit
     Anleitung zur Aktivierung von SSL

Muße

     Gedichte
     Musik
     Bilder

Jenseits des Üblichen ....
    

Besucherstatistik

Besucherstatistik

Monitoring

Montastic status badge