26 包 monorepo(TS 全源码): - snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包 - snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线 - snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI) - snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports) - snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时 - 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏) - 工具件: 解包器/启动器/便携制作器/doctor GitHub 关联已全量指向 DandanLLab/SnowCatQQ
1664 lines
60 KiB
TypeScript
1664 lines
60 KiB
TypeScript
import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid';
|
|
import { MessageUnique } from 'snowcat-common/src/message-unique';
|
|
import {
|
|
ChatType,
|
|
CustomMusicSignPostData,
|
|
ElementType,
|
|
FaceIndex,
|
|
FaceType,
|
|
GrayTipElement,
|
|
GroupNotify,
|
|
IdMusicSignPostData,
|
|
MessageElement,
|
|
SnowCatCore,
|
|
NTGrayTipElementSubTypeV2,
|
|
NTMsgAtType,
|
|
Peer,
|
|
RawMessage,
|
|
SendMessageElement,
|
|
SendTextElement,
|
|
} from 'snowcat-core';
|
|
import faceConfig from 'snowcat-core/external/face_config.json';
|
|
import {
|
|
NapCatOneBot11Adapter,
|
|
OB11Message,
|
|
OB11MessageData,
|
|
OB11MessageDataType,
|
|
OB11MessageFileBase,
|
|
OB11MessageForward,
|
|
OB11MessageImage,
|
|
OB11MessageVideo,
|
|
} from '@/snowcat-onebot/index';
|
|
import { OB11Construct } from '@/snowcat-onebot/helper/data';
|
|
import { EventType } from '@/snowcat-onebot/event/OneBotEvent';
|
|
import { encodeCQCode } from '@/snowcat-onebot/helper/cqcode';
|
|
import { uriToLocalFile } from 'snowcat-common/src/file';
|
|
import { RequestUtil } from 'snowcat-common/src/request';
|
|
import { OB11FriendAddNoticeEvent } from '@/snowcat-onebot/event/notice/OB11FriendAddNoticeEvent';
|
|
import { ForwardMsgBuilder } from '@/snowcat-core/helper/forward-msg-builder';
|
|
import { calculateTimeout, capTimeout } from '@/snowcat-onebot/config/config';
|
|
import { NapProtoMsg } from 'snowcat-protobuf';
|
|
import { OB11GroupIncreaseEvent } from '../event/notice/OB11GroupIncreaseEvent';
|
|
import { GroupDecreaseSubType, OB11GroupDecreaseEvent } from '../event/notice/OB11GroupDecreaseEvent';
|
|
import { GroupAdmin } from 'snowcat-core/packet/transformer/proto/message/groupAdmin';
|
|
import { OB11GroupAdminNoticeEvent } from '../event/notice/OB11GroupAdminNoticeEvent';
|
|
import {
|
|
GroupChange,
|
|
GroupChangeInfo,
|
|
GroupInvite,
|
|
PushMsgBody,
|
|
} from 'snowcat-core/packet/transformer/proto';
|
|
import { OB11GroupRequestEvent } from '../event/request/OB11GroupRequest';
|
|
import { LRUCache } from 'snowcat-common/src/lru-cache';
|
|
import { cleanTaskQueue } from 'snowcat-common/src/clean-task';
|
|
import { registerResource } from 'snowcat-common/src/health';
|
|
import { OB11OnlineFileReceiveEvent } from '@/snowcat-onebot/event/notice/OB11OnlineFileReceiveEvent';
|
|
import { OB11OnlineFileSendEvent } from '@/snowcat-onebot/event/notice/OB11OnlineFileSendEvent';
|
|
|
|
type RawToOb11Converters = {
|
|
[Key in keyof MessageElement as Key extends `${string}Element` ? Key : never]: (
|
|
element: Exclude<MessageElement[Key], null | undefined>,
|
|
msg: RawMessage,
|
|
elementWrapper: MessageElement,
|
|
context: RecvMessageContext
|
|
) => Promise<OB11MessageData | null | undefined>
|
|
};
|
|
|
|
type Ob11ToRawConverters = {
|
|
[Key in OB11MessageDataType]: (
|
|
sendMsg: Extract<OB11MessageData, { type: Key; }>,
|
|
context: SendMessageContext,
|
|
) => Promise<SendMessageElement | undefined>
|
|
};
|
|
|
|
export type SendMessageContext = {
|
|
deleteAfterSentFiles: string[],
|
|
peer: Peer;
|
|
usePacketVideoMetadata?: boolean;
|
|
};
|
|
|
|
export type RecvMessageContext = {
|
|
parseMultMsg: boolean,
|
|
disableGetUrl: boolean,
|
|
quick_reply: boolean;
|
|
};
|
|
|
|
function keyCanBeParsed (key: string, parser: RawToOb11Converters): key is keyof RawToOb11Converters {
|
|
return key in parser;
|
|
}
|
|
|
|
export class OneBotMsgApi {
|
|
obContext: NapCatOneBot11Adapter;
|
|
core: SnowCatCore;
|
|
notifyGroupInvite: LRUCache<string, GroupNotify> = new LRUCache(50);
|
|
// seq -> notify
|
|
rawToOb11Converters: RawToOb11Converters = {
|
|
textElement: async element => {
|
|
if (element.atType === NTMsgAtType.ATTYPEUNKNOWN) {
|
|
let text = element.content;
|
|
// 兼容 9.7.x 换行符
|
|
if (text.indexOf('\n') === -1 && text.indexOf('\r\n') === -1) {
|
|
text = text.replace(/\r/g, '\n');
|
|
}
|
|
return {
|
|
type: OB11MessageDataType.text,
|
|
data: { text },
|
|
};
|
|
} else {
|
|
let qq: string = 'all';
|
|
if (element.atType !== NTMsgAtType.ATTYPEALL) {
|
|
const { atNtUid, atUid } = element;
|
|
qq = !atUid || atUid === '0' ? await this.core.apis.UserApi.getUinByUidV2(atNtUid) : String(Number(atUid) >>> 0);
|
|
}
|
|
return {
|
|
type: OB11MessageDataType.at,
|
|
data: {
|
|
qq,
|
|
// name: content.slice(1);
|
|
},
|
|
};
|
|
}
|
|
},
|
|
|
|
picElement: async (element, msg, elementWrapper, { disableGetUrl }) => {
|
|
try {
|
|
const peer = {
|
|
chatType: msg.chatType,
|
|
peerUid: msg.peerUid,
|
|
guildId: '',
|
|
};
|
|
FileNapCatOneBotUUID.encode(
|
|
peer,
|
|
msg.msgId,
|
|
elementWrapper.elementId,
|
|
element.fileUuid,
|
|
element.fileName
|
|
);
|
|
return {
|
|
type: OB11MessageDataType.image,
|
|
data: {
|
|
summary: element.summary,
|
|
file: element.fileName,
|
|
sub_type: element.picSubType,
|
|
url: disableGetUrl ? (element.filePath ?? '') : await this.core.apis.FileApi.getImageUrl(element),
|
|
file_size: element.fileSize,
|
|
},
|
|
};
|
|
} catch (e) {
|
|
this.core.context.logger.logError('获取图片url失败', (e as Error).stack);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
fileElement: async (element, msg, elementWrapper, { disableGetUrl }) => {
|
|
// 让在线文件/文件夹的消息单独出去(否则无法正确处理UUID!!!)
|
|
if (+elementWrapper.elementType === 23 || +elementWrapper.elementType === 30) {
|
|
// 判断为在线文件/文件夹
|
|
return {
|
|
type: OB11MessageDataType.onlinefile,
|
|
data: {
|
|
msgId: msg.msgId,
|
|
elementId: elementWrapper.elementId,
|
|
fileName: element.fileName,
|
|
fileSize: element.fileSize,
|
|
isDir: (elementWrapper.elementType === 30),
|
|
},
|
|
};
|
|
}
|
|
|
|
const peer = {
|
|
chatType: msg.chatType,
|
|
peerUid: msg.peerUid,
|
|
guildId: '',
|
|
};
|
|
FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileUuid);
|
|
FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName);
|
|
if (this.core.apis.PacketApi.packetStatus && !disableGetUrl) {
|
|
let url;
|
|
try {
|
|
// url = await this.core.apis.FileApi.getFileUrl(msg.chatType, msg.peerUid, element.fileUuid, element.file10MMd5, 1500)
|
|
url = await registerResource(
|
|
'file-url-get',
|
|
{
|
|
resourceFn: async () => {
|
|
return await this.core.apis.FileApi.getFileUrl(msg.chatType, msg.peerUid, element.fileUuid, element.file10MMd5, 1500);
|
|
},
|
|
healthCheckFn: async () => {
|
|
return await this.core.apis.PacketApi.pkt.operation.FetchRkey().then(() => true).catch(() => false);
|
|
},
|
|
testArgs: [],
|
|
healthCheckInterval: 30000,
|
|
maxHealthCheckFailures: 3,
|
|
}
|
|
);
|
|
} catch (_error) {
|
|
url = '';
|
|
}
|
|
if (url) {
|
|
return {
|
|
type: OB11MessageDataType.file,
|
|
data: {
|
|
file: element.fileName,
|
|
file_id: element.fileUuid,
|
|
file_size: element.fileSize,
|
|
url,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
type: OB11MessageDataType.file,
|
|
data: {
|
|
file: element.fileName,
|
|
file_id: element.fileUuid,
|
|
file_size: element.fileSize,
|
|
},
|
|
};
|
|
},
|
|
|
|
faceElement: async element => {
|
|
const faceIndex = element.faceIndex;
|
|
if (element.faceType === FaceType.Poke) {
|
|
return {
|
|
type: OB11MessageDataType.poke,
|
|
data: {
|
|
type: element?.pokeType?.toString() ?? '0',
|
|
id: faceIndex.toString(),
|
|
},
|
|
};
|
|
}
|
|
|
|
if (faceIndex === FaceIndex.DICE) {
|
|
return {
|
|
type: OB11MessageDataType.dice,
|
|
data: {
|
|
result: element.resultId!,
|
|
},
|
|
};
|
|
} else if (faceIndex === FaceIndex.RPS) {
|
|
return {
|
|
type: OB11MessageDataType.rps,
|
|
data: {
|
|
result: element.resultId!,
|
|
},
|
|
};
|
|
} else {
|
|
return {
|
|
type: OB11MessageDataType.face,
|
|
data: {
|
|
id: element.faceIndex.toString(),
|
|
raw: element,
|
|
resultId: element.resultId,
|
|
chainCount: element.chainCount,
|
|
},
|
|
};
|
|
}
|
|
},
|
|
|
|
marketFaceElement: async (_, msg, elementWrapper) => {
|
|
const peer = {
|
|
chatType: msg.chatType,
|
|
peerUid: msg.peerUid,
|
|
guildId: '',
|
|
};
|
|
const { emojiId } = _;
|
|
const dir = emojiId.substring(0, 2);
|
|
const url = `https://gxh.vip.qq.com/club/item/parcel/item/${dir}/${emojiId}/raw300.gif`;
|
|
const filename = `${dir}-${emojiId}.gif`;
|
|
FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, '', filename);
|
|
return {
|
|
type: OB11MessageDataType.image,
|
|
data: {
|
|
summary: _.faceName, // 商城表情名称
|
|
file: filename,
|
|
url,
|
|
key: _.key,
|
|
emoji_id: _.emojiId,
|
|
emoji_package_id: _.emojiPackageId,
|
|
},
|
|
};
|
|
},
|
|
|
|
replyElement: async (element, msg, _, { quick_reply }) => {
|
|
const peer = {
|
|
chatType: msg.chatType,
|
|
peerUid: msg.peerUid,
|
|
guildId: '',
|
|
};
|
|
|
|
// 创建回复数据的通用方法
|
|
const createReplyData = (msgId: string): OB11MessageData => ({
|
|
type: OB11MessageDataType.reply,
|
|
data: {
|
|
id: MessageUnique.createUniqueMsgId(peer, msgId).toString(),
|
|
},
|
|
});
|
|
|
|
// 查找记录
|
|
const records = msg.records.find(msgRecord => msgRecord.msgId === element?.sourceMsgIdInRecords);
|
|
|
|
// 特定账号的特殊处理
|
|
if (records && (records.peerUin === '284840486' || records.peerUin === '1094950020')) {
|
|
return createReplyData(records.msgId);
|
|
}
|
|
|
|
// 获取消息的通用方法组
|
|
const tryFetchMethods = async (msgSeq: string, senderUid?: string, msgTime?: string, msgRandom?: string): Promise<RawMessage | undefined> => {
|
|
try {
|
|
// 方法1:通过序号和时间筛选
|
|
if (senderUid && msgTime) {
|
|
const replyMsgList = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeqV2(
|
|
peer, msgSeq, msgTime, [senderUid]
|
|
)).msgList;
|
|
|
|
const replyMsg = replyMsgList.find(msg =>
|
|
(msgRandom && msg.msgRandom === msgRandom) || msg.msgSeq === msgSeq
|
|
);
|
|
|
|
if (replyMsg) return replyMsg;
|
|
if (quick_reply) {
|
|
this.core.context.logger.logWarn(`快速回复,跳过方法1查询,序号: ${msgSeq}, 消息数: ${replyMsgList.length}`);
|
|
return undefined;
|
|
}
|
|
this.core.context.logger.logWarn(`方法1查询失败,序号: ${msgSeq}, 消息数: ${replyMsgList.length}`);
|
|
}
|
|
|
|
// 方法2:直接通过序号获取
|
|
const replyMsgList = (await this.core.apis.MsgApi.getMsgsBySeqAndCount(
|
|
peer, msgSeq, 1, true, true
|
|
)).msgList;
|
|
|
|
const replyMsg = replyMsgList.find(msg =>
|
|
(msgRandom && msg.msgRandom === msgRandom) || msg.msgSeq === msgSeq
|
|
);
|
|
|
|
if (replyMsg) return replyMsg;
|
|
|
|
this.core.context.logger.logWarn(`方法2查询失败,序号: ${msgSeq}, 消息数: ${replyMsgList.length}`);
|
|
|
|
// 方法3:另一种筛选方式
|
|
if (senderUid) {
|
|
const replyMsgList = (await this.core.apis.MsgApi.queryMsgsWithFilterExWithSeqV3(
|
|
peer, msgSeq, [senderUid]
|
|
)).msgList;
|
|
|
|
const replyMsg = replyMsgList.find(msg =>
|
|
(msgRandom && msg.msgRandom === msgRandom) || msg.msgSeq === msgSeq
|
|
);
|
|
|
|
if (replyMsg) return replyMsg;
|
|
|
|
this.core.context.logger.logWarn(`方法3查询失败,序号: ${msgSeq}, 消息数: ${replyMsgList.length}`);
|
|
}
|
|
|
|
return undefined;
|
|
} catch (error) {
|
|
this.core.context.logger.logError('查询回复消息出错', error);
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
// 有记录情况下,使用完整信息查询
|
|
if (records && element.replyMsgTime && element.senderUidStr) {
|
|
const replyMsg = await tryFetchMethods(
|
|
element.replayMsgSeq,
|
|
element.senderUidStr,
|
|
records.msgTime,
|
|
records.msgRandom
|
|
);
|
|
|
|
if (replyMsg) {
|
|
return createReplyData(replyMsg.msgId);
|
|
}
|
|
|
|
this.core.context.logger.logError('所有查找方法均失败,获取不到带记录的引用消息', element.replayMsgSeq);
|
|
} else {
|
|
// 旧版客户端或不完整记录的情况,也尝试使用相同流程
|
|
this.core.context.logger.logWarn('似乎是旧版客户端,尝试仅通过序号获取引用消息', element.replayMsgSeq);
|
|
|
|
const replyMsg = await tryFetchMethods(element.replayMsgSeq);
|
|
|
|
if (replyMsg) {
|
|
return createReplyData(replyMsg.msgId);
|
|
}
|
|
|
|
this.core.context.logger.logError('所有查找方法均失败,获取不到旧客户端的引用消息', element.replayMsgSeq);
|
|
}
|
|
|
|
// 协议兜底:尝试通过协议直接获取消息
|
|
if (this.core.apis.PacketApi.packetStatus) {
|
|
try {
|
|
const msgSeq = +element.replayMsgSeq;
|
|
const fetchedMsgs = msg.chatType === ChatType.KCHATTYPEGROUP
|
|
? await this.core.apis.PacketApi.pkt.operation.FetchGroupMessage(+msg.peerUin, msgSeq, msgSeq)
|
|
: await this.core.apis.PacketApi.pkt.operation.FetchC2CMessage(msg.peerUid, msgSeq, msgSeq);
|
|
|
|
const targetMsg = fetchedMsgs.find(m => (m.contentHead.sequence ?? 0) === msgSeq);
|
|
if (targetMsg?.contentHead?.newId) {
|
|
const msgId = String(targetMsg.contentHead.newId);
|
|
this.core.context.logger.logWarn('通过协议兜底成功获取引用消息', msgId);
|
|
return createReplyData(msgId);
|
|
}
|
|
this.core.context.logger.logWarn('协议兜底未找到匹配的引用消息');
|
|
} catch (e) {
|
|
this.core.context.logger.logError('协议兜底获取引用消息失败', (e as Error).stack);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
videoElement: async (element, msg, elementWrapper, { disableGetUrl }) => {
|
|
const peer = {
|
|
chatType: msg.chatType,
|
|
peerUid: msg.peerUid,
|
|
guildId: '',
|
|
};
|
|
// 读取视频链接并兜底
|
|
let videoUrlWrappers: Awaited<ReturnType<typeof this.core.apis.FileApi.getVideoUrl>> | undefined;
|
|
|
|
if (msg.peerUin === '284840486' || msg.peerUin === '1094950020') {
|
|
try {
|
|
videoUrlWrappers = await this.core.apis.FileApi.getVideoUrl({
|
|
chatType: msg.chatType,
|
|
peerUid: msg.peerUid,
|
|
guildId: '0',
|
|
}, msg.parentMsgIdList[0] ?? msg.msgId, elementWrapper.elementId);
|
|
} catch {
|
|
this.core.context.logger.logWarn('合并获取视频 URL 失败');
|
|
}
|
|
} else {
|
|
try {
|
|
videoUrlWrappers = await this.core.apis.FileApi.getVideoUrl({
|
|
chatType: msg.chatType,
|
|
peerUid: msg.peerUid,
|
|
guildId: '0',
|
|
}, msg.msgId, elementWrapper.elementId);
|
|
} catch {
|
|
this.core.context.logger.logWarn('获取视频 URL 失败');
|
|
}
|
|
}
|
|
|
|
// 读取在线URL
|
|
let videoDownUrl: string | undefined;
|
|
|
|
if (videoUrlWrappers) {
|
|
const videoDownUrlTemp = videoUrlWrappers.find((urlWrapper) => {
|
|
return !!(urlWrapper.url);
|
|
});
|
|
if (videoDownUrlTemp) {
|
|
videoDownUrl = videoDownUrlTemp.url;
|
|
}
|
|
}
|
|
|
|
// 开始兜底
|
|
if (!videoDownUrl && !disableGetUrl) {
|
|
if (this.core.apis.PacketApi.packetStatus) {
|
|
try {
|
|
// videoDownUrl = await this.core.apis.FileApi.getVideoUrlPacket(msg.peerUid, element.fileUuid, 1500);
|
|
videoDownUrl = await registerResource(
|
|
'video-url-get',
|
|
{
|
|
resourceFn: async () => {
|
|
return await this.core.apis.FileApi.getVideoUrlPacket(msg.peerUid, element.fileUuid, 1500);
|
|
},
|
|
healthCheckFn: async () => {
|
|
return await this.core.apis.PacketApi.pkt.operation.FetchRkey().then(() => true).catch(() => false);
|
|
},
|
|
testArgs: [],
|
|
healthCheckInterval: 30000,
|
|
maxHealthCheckFailures: 3,
|
|
}
|
|
);
|
|
} catch (e) {
|
|
this.core.context.logger.logError('获取视频url失败', (e as Error).stack);
|
|
videoDownUrl = element.filePath;
|
|
}
|
|
} else {
|
|
videoDownUrl = element.filePath;
|
|
}
|
|
}
|
|
const fileCode = FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, element.fileUuid, element.fileName);
|
|
return {
|
|
type: OB11MessageDataType.video,
|
|
data: {
|
|
file: fileCode,
|
|
url: videoDownUrl,
|
|
file_size: element.fileSize,
|
|
},
|
|
};
|
|
},
|
|
|
|
pttElement: async (element, msg, elementWrapper, { disableGetUrl }) => {
|
|
const peer = {
|
|
chatType: msg.chatType,
|
|
peerUid: msg.peerUid,
|
|
guildId: '',
|
|
};
|
|
const fileCode = FileNapCatOneBotUUID.encode(peer, msg.msgId, elementWrapper.elementId, '', element.fileName);
|
|
let pttUrl = '';
|
|
if (this.core.apis.PacketApi.packetStatus && !disableGetUrl) {
|
|
try {
|
|
pttUrl = await registerResource(
|
|
'ptt-url-get',
|
|
{
|
|
resourceFn: async () => {
|
|
return await this.core.apis.FileApi.getPttUrl(msg.peerUid, element.fileUuid, 1500);
|
|
},
|
|
healthCheckFn: async () => {
|
|
return await this.core.apis.PacketApi.pkt.operation.FetchRkey().then(() => true).catch(() => false);
|
|
},
|
|
testArgs: [],
|
|
healthCheckInterval: 30000,
|
|
maxHealthCheckFailures: 3,
|
|
}
|
|
);
|
|
// pttUrl = await this.core.apis.FileApi.getPttUrl(msg.peerUid, element.fileUuid, 1500);
|
|
} catch (e) {
|
|
this.core.context.logger.logError('获取语音url失败', (e as Error).stack);
|
|
pttUrl = element.filePath;
|
|
}
|
|
} else {
|
|
pttUrl = element.filePath;
|
|
}
|
|
if (pttUrl) {
|
|
return {
|
|
type: OB11MessageDataType.voice,
|
|
data: {
|
|
file: fileCode,
|
|
path: element.filePath,
|
|
url: pttUrl,
|
|
file_size: element.fileSize,
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
type: OB11MessageDataType.voice,
|
|
data: {
|
|
file: fileCode,
|
|
file_size: element.fileSize,
|
|
path: element.filePath,
|
|
},
|
|
};
|
|
},
|
|
|
|
multiForwardMsgElement: async (element, msg, _wrapper, context) => {
|
|
const parentMsgPeer = msg.parentMsgPeer ?? {
|
|
chatType: msg.chatType,
|
|
guildId: '',
|
|
peerUid: msg.peerUid,
|
|
};
|
|
let multiMsgs = await this.getMultiMessages(msg, parentMsgPeer);
|
|
// 拉取失败则跳过
|
|
if (!multiMsgs || multiMsgs.length === 0) {
|
|
try {
|
|
multiMsgs = await this.core.apis.PacketApi.pkt.operation.FetchForwardMsg(element.resId);
|
|
} catch (e) {
|
|
this.core.context.logger.logError(`Protocol FetchForwardMsg fallback failed!
|
|
element = ${JSON.stringify(element)} , error=${e})`);
|
|
return null;
|
|
}
|
|
}
|
|
const forward: OB11MessageForward = {
|
|
type: OB11MessageDataType.forward,
|
|
data: { id: msg.msgId },
|
|
};
|
|
if (!context.parseMultMsg) return forward;
|
|
forward.data.content = await this.parseMultiMessageContent(
|
|
multiMsgs,
|
|
parentMsgPeer,
|
|
msg.parentMsgIdList
|
|
);
|
|
return forward;
|
|
},
|
|
|
|
arkElement: async (element) => {
|
|
return {
|
|
type: OB11MessageDataType.json,
|
|
data: {
|
|
data: element.bytesData,
|
|
},
|
|
};
|
|
},
|
|
|
|
markdownElement: async (element) => {
|
|
// 让QQ闪传消息独立出去
|
|
if (element?.mdExtInfo?.flashTransferInfo?.filesetId) {
|
|
return {
|
|
type: OB11MessageDataType.flashtransfer,
|
|
data: {
|
|
fileSetId: element.mdExtInfo.flashTransferInfo.filesetId,
|
|
},
|
|
};
|
|
} else {
|
|
return {
|
|
type: OB11MessageDataType.markdown,
|
|
data: {
|
|
content: element.content,
|
|
},
|
|
};
|
|
}
|
|
},
|
|
};
|
|
|
|
ob11ToRawConverters: Ob11ToRawConverters = {
|
|
[OB11MessageDataType.text]: async ({ data: { text } }) => ({
|
|
elementType: ElementType.TEXT,
|
|
elementId: '',
|
|
textElement: {
|
|
content: text,
|
|
atType: NTMsgAtType.ATTYPEUNKNOWN,
|
|
atUid: '',
|
|
atTinyId: '',
|
|
atNtUid: '',
|
|
},
|
|
}),
|
|
|
|
[OB11MessageDataType.at]: async ({ data: { qq: atQQ } }, context) => {
|
|
function at (atUid: string, atNtUid: string, atType: NTMsgAtType, atName: string): SendTextElement {
|
|
return {
|
|
elementType: ElementType.TEXT,
|
|
elementId: '',
|
|
textElement: {
|
|
content: `@${atName}`,
|
|
atType,
|
|
atUid,
|
|
atTinyId: '',
|
|
atNtUid,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (!context.peer || !atQQ || context.peer.chatType === ChatType.KCHATTYPEC2C) return undefined; // 过滤掉空atQQ
|
|
if (atQQ === 'all') return at(atQQ, atQQ, NTMsgAtType.ATTYPEALL, '全体成员');
|
|
const atMember = await this.core.apis.GroupApi.getGroupMember(context.peer.peerUid, atQQ);
|
|
if (atMember) {
|
|
return at(atQQ, atMember.uid, NTMsgAtType.ATTYPEONE, atMember.nick || atMember.cardName);
|
|
}
|
|
const uid = await this.core.apis.UserApi.getUidByUinV2(`${atQQ}`);
|
|
if (!uid) throw new Error('Get Uid Error');
|
|
const info = await this.core.apis.UserApi.getUserDetailInfo(uid);
|
|
return at(atQQ, uid, NTMsgAtType.ATTYPEONE, info.nick || '');
|
|
},
|
|
|
|
[OB11MessageDataType.reply]: async ({ data: { id, seq } }, context) => {
|
|
let replyMsg: RawMessage | undefined;
|
|
let replyMsgPeer: Peer | undefined;
|
|
|
|
// 优先使用 seq
|
|
if (seq) {
|
|
const msgList = (await this.core.apis.MsgApi.getMsgsBySeqAndCount(
|
|
context.peer, seq.toString(), 1, true, true
|
|
)).msgList;
|
|
replyMsg = msgList[0];
|
|
replyMsgPeer = context.peer;
|
|
} else if (id) {
|
|
// 降级使用 id
|
|
const replyMsgM = MessageUnique.getMsgIdAndPeerByShortId(parseInt(id));
|
|
if (!replyMsgM) {
|
|
this.core.context.logger.logWarn('回复消息不存在', id);
|
|
return undefined;
|
|
}
|
|
replyMsg = (await this.core.apis.MsgApi.getMsgsByMsgId(
|
|
replyMsgM.Peer, [replyMsgM.MsgId])).msgList[0];
|
|
replyMsgPeer = replyMsgM.Peer;
|
|
} else {
|
|
this.core.context.logger.logWarn('回复消息缺少id或seq参数');
|
|
return undefined;
|
|
}
|
|
|
|
return replyMsg && replyMsgPeer
|
|
? {
|
|
elementType: ElementType.REPLY,
|
|
elementId: '',
|
|
replyElement: {
|
|
replayMsgSeq: replyMsg.msgSeq, // raw.msgSeq
|
|
replayMsgId: replyMsg.msgId, // raw.msgId
|
|
senderUin: replyMsg.senderUin,
|
|
senderUinStr: replyMsg.senderUin,
|
|
replyMsgClientSeq: replyMsg.clientSeq,
|
|
_replyMsgPeer: replyMsgPeer,
|
|
},
|
|
}
|
|
: undefined;
|
|
},
|
|
|
|
[OB11MessageDataType.face]: async ({ data: { id, resultId, chainCount } }) => {
|
|
const parsedFaceId = +id;
|
|
// 从face_config.json中获取表情名称
|
|
const sysFaces = faceConfig.sysface;
|
|
const face: {
|
|
QSid?: string,
|
|
QDes?: string,
|
|
AniStickerId?: string,
|
|
AniStickerType?: number,
|
|
AniStickerPackId?: string,
|
|
} | undefined = sysFaces.find((systemFace) => systemFace.QSid === parsedFaceId.toString());
|
|
if (!face) {
|
|
this.core.context.logger.logError('不支持的ID', id);
|
|
return undefined;
|
|
}
|
|
let faceType = 1;
|
|
if (parsedFaceId >= 222) {
|
|
faceType = 2;
|
|
}
|
|
if (face.AniStickerType) {
|
|
faceType = 3;
|
|
}
|
|
return {
|
|
elementType: ElementType.FACE,
|
|
elementId: '',
|
|
faceElement: {
|
|
faceIndex: parsedFaceId,
|
|
faceType,
|
|
faceText: face.QDes,
|
|
stickerId: face.AniStickerId,
|
|
stickerType: face.AniStickerType,
|
|
packId: face.AniStickerPackId,
|
|
sourceType: 1,
|
|
resultId: resultId?.toString(),
|
|
chainCount,
|
|
},
|
|
};
|
|
},
|
|
[OB11MessageDataType.mface]: async ({
|
|
data: {
|
|
emoji_package_id, emoji_id, key, summary,
|
|
},
|
|
}) => ({
|
|
elementType: ElementType.MFACE,
|
|
elementId: '',
|
|
marketFaceElement: {
|
|
emojiPackageId: emoji_package_id,
|
|
emojiId: emoji_id,
|
|
key,
|
|
faceName: summary || '[商城表情]',
|
|
},
|
|
}),
|
|
|
|
// File service
|
|
[OB11MessageDataType.image]: async (sendMsg, context) => {
|
|
const result = await this.handleOb11FileLikeMessage(sendMsg, context);
|
|
return await this.obContext.apis.FileApi.createValidSendPicElement(
|
|
context,
|
|
result.path,
|
|
sendMsg.data.summary,
|
|
sendMsg.data.sub_type,
|
|
result.isLocal
|
|
);
|
|
},
|
|
|
|
[OB11MessageDataType.file]: async (sendMsg, context) => {
|
|
const { path, fileName, isLocal } = await this.handleOb11FileLikeMessage(sendMsg, context);
|
|
return await this.obContext.apis.FileApi.createValidSendFileElement(context, path, fileName, '', false, isLocal);
|
|
},
|
|
|
|
[OB11MessageDataType.video]: async (sendMsg, context) => {
|
|
const { path, fileName, isLocal } = await this.handleOb11FileLikeMessage(sendMsg, context);
|
|
|
|
let thumb = sendMsg.data.thumb;
|
|
if (thumb) {
|
|
const uri2LocalRes = await uriToLocalFile(this.core.SnowCatTempPath, thumb);
|
|
if (uri2LocalRes.success) {
|
|
thumb = uri2LocalRes.path;
|
|
if (!uri2LocalRes.isLocal) {
|
|
context.deleteAfterSentFiles.push(thumb);
|
|
}
|
|
}
|
|
}
|
|
|
|
return await this.obContext.apis.FileApi.createValidSendVideoElement(context, path, fileName, thumb, isLocal);
|
|
},
|
|
|
|
[OB11MessageDataType.voice]: async (sendMsg, context) =>
|
|
this.obContext.apis.FileApi.createValidSendPttElement(context,
|
|
(await this.handleOb11FileLikeMessage(sendMsg, context)).path),
|
|
|
|
[OB11MessageDataType.json]: async ({ data: { data } }) => ({
|
|
elementType: ElementType.ARK,
|
|
elementId: '',
|
|
arkElement: {
|
|
bytesData: typeof data === 'string' ? data : JSON.stringify(data),
|
|
linkInfo: null,
|
|
subElementType: null,
|
|
},
|
|
}),
|
|
|
|
[OB11MessageDataType.dice]: async () => ({
|
|
elementType: ElementType.FACE,
|
|
elementId: '',
|
|
faceElement: {
|
|
faceIndex: FaceIndex.DICE,
|
|
faceType: FaceType.AniSticke,
|
|
faceText: '[骰子]',
|
|
packId: '1',
|
|
stickerId: '33',
|
|
sourceType: 1,
|
|
stickerType: 2,
|
|
surpriseId: '',
|
|
// "randomType": 1,
|
|
},
|
|
}),
|
|
|
|
[OB11MessageDataType.rps]: async () => ({
|
|
elementType: ElementType.FACE,
|
|
elementId: '',
|
|
faceElement: {
|
|
faceIndex: FaceIndex.RPS,
|
|
faceText: '[包剪锤]',
|
|
faceType: FaceType.AniSticke,
|
|
packId: '1',
|
|
stickerId: '34',
|
|
sourceType: 1,
|
|
stickerType: 2,
|
|
surpriseId: '',
|
|
// "randomType": 1,
|
|
},
|
|
}),
|
|
|
|
// Need signing
|
|
[OB11MessageDataType.markdown]: async ({ data: { content } }) => ({
|
|
elementType: ElementType.MARKDOWN,
|
|
elementId: '',
|
|
markdownElement: { content },
|
|
}),
|
|
|
|
[OB11MessageDataType.music]: async ({ data }, context) => {
|
|
// 保留, 直到...找到更好的解决方案
|
|
const supportedPlatforms = ['qq', '163', 'kugou', 'kuwo', 'migu'];
|
|
const supportedPlatformsWithCustom = [...supportedPlatforms, 'custom'];
|
|
|
|
// 验证音乐类型
|
|
if (data.id !== undefined) {
|
|
if (!supportedPlatforms.includes(data.type)) {
|
|
this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatforms.join('、')}`);
|
|
return undefined;
|
|
}
|
|
} else {
|
|
if (!supportedPlatformsWithCustom.includes(data.type)) {
|
|
this.core.context.logger.logError(`[音乐卡片] type参数错误: "${data.type}",仅支持: ${supportedPlatformsWithCustom.join('、')}`);
|
|
return undefined;
|
|
}
|
|
if (!data.url) {
|
|
this.core.context.logger.logError('[音乐卡片] 自定义音乐卡片缺少必需参数: url');
|
|
return undefined;
|
|
}
|
|
if (!data.image) {
|
|
this.core.context.logger.logError('[音乐卡片] 自定义音乐卡片缺少必需参数: image');
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// 构建请求数据
|
|
let postData: IdMusicSignPostData | CustomMusicSignPostData;
|
|
if (data.id === undefined && data.content) {
|
|
const { content, ...others } = data;
|
|
postData = { singer: content, ...others };
|
|
} else {
|
|
postData = data;
|
|
}
|
|
|
|
// 获取签名服务地址
|
|
let signUrl = this.obContext.configLoader.configData.musicSignUrl;
|
|
if (!signUrl) {
|
|
signUrl = 'http://106.55.0.102:10087/'; // 首选,yibai音卡签名,已更换稳定签名服务器
|
|
}
|
|
|
|
// 请求签名服务
|
|
try {
|
|
let musicJson: string;
|
|
try {
|
|
musicJson = await RequestUtil.HttpGetJson<string>(signUrl, 'POST', postData);
|
|
} catch {
|
|
// 首选失败,降级到备选
|
|
this.core.context.logger.logError('[音乐卡片签名] 首选地址失败,尝试备选地址...');
|
|
musicJson = await RequestUtil.HttpGetJson<string>(
|
|
'https://ss.xingzhige.com/music_card/card', // 思思的音卡签名
|
|
'POST',
|
|
postData
|
|
);
|
|
}
|
|
return this.ob11ToRawConverters.json({
|
|
data: { data: musicJson },
|
|
type: OB11MessageDataType.json,
|
|
}, context);
|
|
} catch (e) {
|
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
this.core.context.logger.logError(
|
|
'[音乐卡片签名失败] 签名服务请求出错!\n' +
|
|
` ├─ 音乐类型: ${data.type}\n` +
|
|
` ├─ 音乐ID: ${data.id ?? '自定义'}\n` +
|
|
` ├─ 错误信息: ${errorMessage}\n` +
|
|
' └─ 提示: 请检查网络连接,或尝试在配置中更换其他音乐签名服务地址(musicSignUrl)'
|
|
);
|
|
return undefined;
|
|
}
|
|
},
|
|
|
|
[OB11MessageDataType.node]: async () => undefined,
|
|
|
|
[OB11MessageDataType.forward]: async ({ data }, context) => {
|
|
// let id = data.id.toString();
|
|
// let peer: Peer | undefined = context.peer;
|
|
// if (isNumeric(id)) {
|
|
// let msgid = '';
|
|
// if (BigInt(data.id) > 2147483647n) {
|
|
// peer = MessageUnique.getPeerByMsgId(id)?.Peer;
|
|
// msgid = id;
|
|
// } else {
|
|
// let data = MessageUnique.getMsgIdAndPeerByShortId(parseInt(id));
|
|
// msgid = data?.MsgId ?? '';
|
|
// peer = data?.Peer;
|
|
// }
|
|
// }
|
|
const jsonData = ForwardMsgBuilder.fromResId(data.id);
|
|
return this.ob11ToRawConverters.json({
|
|
data: { data: JSON.stringify(jsonData) },
|
|
type: OB11MessageDataType.json,
|
|
}, context);
|
|
},
|
|
|
|
[OB11MessageDataType.xml]: async () => undefined,
|
|
|
|
[OB11MessageDataType.poke]: async () => undefined,
|
|
|
|
[OB11MessageDataType.location]: async () => ({
|
|
elementType: ElementType.SHARELOCATION,
|
|
elementId: '',
|
|
shareLocationElement: {
|
|
text: '测试',
|
|
ext: '',
|
|
},
|
|
}),
|
|
|
|
[OB11MessageDataType.miniapp]: async () => undefined,
|
|
|
|
[OB11MessageDataType.contact]: async ({ data: { type = 'qq', id } }, context) => {
|
|
if (type === 'qq') {
|
|
const arkJson = await this.core.apis.UserApi.getBuddyRecommendContactArkJson(id.toString(), '');
|
|
return this.ob11ToRawConverters.json({
|
|
data: { data: arkJson.arkMsg },
|
|
type: OB11MessageDataType.json,
|
|
}, context);
|
|
} else if (type === 'group') {
|
|
const arkJson = await this.core.apis.GroupApi.getGroupRecommendContactArkJson(id.toString());
|
|
return this.ob11ToRawConverters.json({
|
|
data: { data: arkJson.arkJson },
|
|
type: OB11MessageDataType.json,
|
|
}, context);
|
|
}
|
|
return undefined;
|
|
},
|
|
// 不需要支持发送
|
|
[OB11MessageDataType.onlinefile]: async () => undefined,
|
|
|
|
[OB11MessageDataType.flashtransfer]: async () => undefined,
|
|
};
|
|
|
|
constructor (obContext: NapCatOneBot11Adapter, core: SnowCatCore) {
|
|
this.obContext = obContext;
|
|
this.core = core;
|
|
}
|
|
|
|
/**
|
|
* 解析带有JSON标记的文本
|
|
* @param text 要解析的文本
|
|
* @returns 解析后的结果数组,每个元素包含类型(text或json)和内容
|
|
*/
|
|
parseTextWithJson (text: string) {
|
|
// 匹配<{...}>格式的JSON
|
|
const regex = /<(\{.*?\})>/g;
|
|
const parts: Array<{ type: 'text' | 'json', content: string | object; }> = [];
|
|
let lastIndex = 0;
|
|
let match;
|
|
|
|
// 查找所有匹配项
|
|
while ((match = regex.exec(text)) !== null) {
|
|
// 添加匹配前的文本
|
|
if (match.index > lastIndex) {
|
|
parts.push({
|
|
type: 'text',
|
|
content: text.substring(lastIndex, match.index),
|
|
});
|
|
}
|
|
|
|
// 添加JSON部分
|
|
try {
|
|
const jsonContent = JSON.parse(match[1] ?? '');
|
|
parts.push({
|
|
type: 'json',
|
|
content: jsonContent,
|
|
});
|
|
} catch (_e) {
|
|
// 如果JSON解析失败,作为普通文本处理
|
|
parts.push({
|
|
type: 'text',
|
|
content: match[0],
|
|
});
|
|
}
|
|
|
|
lastIndex = regex.lastIndex;
|
|
}
|
|
|
|
// 添加最后一部分文本
|
|
if (lastIndex < text.length) {
|
|
parts.push({
|
|
type: 'text',
|
|
content: text.substring(lastIndex),
|
|
});
|
|
}
|
|
|
|
return parts;
|
|
}
|
|
|
|
async parsePrivateMsgEvent (msg: RawMessage, grayTipElement: GrayTipElement) {
|
|
if (grayTipElement.subElementType === NTGrayTipElementSubTypeV2.GRAYTIP_ELEMENT_SUBTYPE_JSON) {
|
|
if (grayTipElement.jsonGrayTipElement.busiId.toString() === '1061') {
|
|
const PokeEvent = await this.obContext.apis.FriendApi.parsePrivatePokeEvent(grayTipElement, Number(await this.core.apis.UserApi.getUinByUidV2(msg.peerUid)));
|
|
if (PokeEvent) {
|
|
return PokeEvent;
|
|
}
|
|
} else if (grayTipElement.jsonGrayTipElement.busiId.toString() === '19324' && msg.peerUid !== '') {
|
|
return new OB11FriendAddNoticeEvent(this.core, Number(await this.core.apis.UserApi.getUinByUidV2(msg.peerUid)));
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private async getMultiMessages (msg: RawMessage, parentMsgPeer: Peer) {
|
|
// 判断是否在合并消息内
|
|
msg.parentMsgIdList = msg.parentMsgIdList ?? [];
|
|
// 首次列表不存在则开始创建
|
|
msg.parentMsgIdList.push(msg.msgId);
|
|
// 拉取下级消息
|
|
if (msg.parentMsgIdList[0]) {
|
|
return (await this.core.apis.MsgApi.getMultiMsg(
|
|
parentMsgPeer,
|
|
msg.parentMsgIdList[0],
|
|
msg.msgId
|
|
))?.msgList;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private async parseMultiMessageContent (
|
|
multiMsgs: RawMessage[],
|
|
parentMsgPeer: Peer,
|
|
parentMsgIdList: string[]
|
|
) {
|
|
const parsed = await Promise.all(multiMsgs.map(async msg => {
|
|
msg.parentMsgPeer = parentMsgPeer;
|
|
msg.parentMsgIdList = parentMsgIdList;
|
|
msg.id = MessageUnique.createUniqueMsgId(parentMsgPeer, msg.msgId);
|
|
// 该ID仅用查看 无法调用
|
|
return await this.parseMessage(msg, 'array', true);
|
|
}));
|
|
return parsed.filter(item => item !== undefined);
|
|
}
|
|
|
|
async parseMessage (
|
|
msg: RawMessage,
|
|
messagePostFormat: string,
|
|
parseMultMsg: boolean = true,
|
|
disableGetUrl: boolean = false,
|
|
quick_reply: boolean = false
|
|
) {
|
|
if (messagePostFormat === 'string') {
|
|
return (await this.parseMessageV2(msg, parseMultMsg, disableGetUrl, quick_reply))?.stringMsg;
|
|
}
|
|
return (await this.parseMessageV2(msg, parseMultMsg, disableGetUrl, quick_reply))?.arrayMsg;
|
|
}
|
|
|
|
async parseMessageV2 (
|
|
msg: RawMessage,
|
|
parseMultMsg: boolean = true,
|
|
disableGetUrl: boolean = false,
|
|
quick_reply: boolean = false
|
|
) {
|
|
if ((msg.senderUin === '0' || msg.senderUin === '')) {
|
|
if (msg.senderUid && msg.senderUid !== '' && msg.senderUid !== '0') {
|
|
msg.senderUin = await this.core.apis.UserApi.getUinByUidV2(msg.senderUid);
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}
|
|
if (msg.peerUin === '0' || msg.peerUin === '') {
|
|
if (msg.peerUid && msg.peerUid !== '' && msg.peerUid !== '0') {
|
|
msg.peerUin = await this.core.apis.UserApi.getUinByUidV2(msg.peerUid);
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
const resMsg = this.initializeMessage(msg);
|
|
|
|
if (this.core.selfInfo.uin === msg.senderUin) {
|
|
resMsg.message_sent_type = 'self';
|
|
}
|
|
|
|
if (msg.chatType === ChatType.KCHATTYPEGROUP) {
|
|
await this.handleGroupMessage(resMsg, msg);
|
|
} else if (msg.chatType === ChatType.KCHATTYPEC2C) {
|
|
await this.handlePrivateMessage(resMsg, msg);
|
|
} else if (msg.chatType === ChatType.KCHATTYPETEMPC2CFROMGROUP) {
|
|
await this.handleTempGroupMessage(resMsg, msg);
|
|
} else {
|
|
return undefined;
|
|
}
|
|
|
|
const validSegments = await this.parseMessageSegments(msg, parseMultMsg, disableGetUrl, quick_reply);
|
|
resMsg.message = validSegments;
|
|
resMsg.raw_message = validSegments.map(msg => encodeCQCode(msg)).join('').trim();
|
|
|
|
const stringMsg = await this.convertArrayToStringMessage(resMsg);
|
|
return { stringMsg, arrayMsg: resMsg };
|
|
}
|
|
|
|
private initializeMessage (msg: RawMessage): OB11Message {
|
|
return {
|
|
self_id: parseInt(this.core.selfInfo.uin),
|
|
user_id: parseInt(msg.senderUin),
|
|
time: parseInt(msg.msgTime) || Date.now(),
|
|
message_id: msg.id!,
|
|
message_seq: msg.id!,
|
|
real_id: msg.id!,
|
|
real_seq: msg.msgSeq,
|
|
message_type: msg.chatType === ChatType.KCHATTYPEGROUP ? 'group' : 'private',
|
|
sender: {
|
|
user_id: +(msg.senderUin ?? 0),
|
|
nickname: msg.sendNickName,
|
|
card: msg.sendMemberName ?? '',
|
|
},
|
|
raw_message: '',
|
|
font: 14,
|
|
sub_type: 'friend',
|
|
message: [],
|
|
message_format: 'array',
|
|
post_type: this.core.selfInfo.uin === msg.senderUin ? EventType.MESSAGE_SENT : EventType.MESSAGE,
|
|
};
|
|
}
|
|
|
|
private async handleGroupMessage (resMsg: OB11Message, msg: RawMessage) {
|
|
resMsg.sub_type = 'normal';
|
|
resMsg.group_id = parseInt(msg.peerUin);
|
|
resMsg.group_name = msg.peerName;
|
|
let member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin);
|
|
if (!member) member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, msg.senderUin);
|
|
if (member) {
|
|
resMsg.sender.role = OB11Construct.groupMemberRole(member.role);
|
|
resMsg.sender.nickname = member.nick;
|
|
}
|
|
}
|
|
|
|
private async handlePrivateMessage (resMsg: OB11Message, msg: RawMessage) {
|
|
resMsg.sub_type = 'friend';
|
|
if (await this.core.apis.FriendApi.isBuddy(msg.senderUid)) {
|
|
const nickname = (await this.core.apis.UserApi.getCoreAndBaseInfo([msg.senderUid])).get(msg.senderUid)?.coreInfo.nick;
|
|
if (nickname) {
|
|
resMsg.sender.nickname = nickname;
|
|
return;
|
|
}
|
|
}
|
|
resMsg.sender.nickname = (await this.core.apis.UserApi.getUserDetailInfo(msg.senderUid)).nick;
|
|
}
|
|
|
|
private async handleTempGroupMessage (resMsg: OB11Message, msg: RawMessage) {
|
|
resMsg.sub_type = 'group';
|
|
const ret = await this.core.apis.MsgApi.getTempChatInfo(ChatType.KCHATTYPETEMPC2CFROMGROUP, msg.senderUid);
|
|
if (ret.result === 0) {
|
|
// 避免uin:'' uid非空,uid一般不空
|
|
const member = await this.core.apis.GroupApi.getGroupMember(msg.peerUin, await this.core.apis.UserApi.getUinByUidV2(msg.senderUid));
|
|
resMsg.group_id = parseInt(ret.tmpChatInfo!.groupCode);
|
|
resMsg.sender.nickname = member?.nick ?? member?.cardName ?? '临时会话';
|
|
resMsg.temp_source = 0;
|
|
} else {
|
|
resMsg.group_id = 284840486;
|
|
resMsg.temp_source = 0;
|
|
resMsg.sender.nickname = '临时会话';
|
|
}
|
|
}
|
|
|
|
private async parseMessageSegments (msg: RawMessage, parseMultMsg: boolean, disableGetUrl: boolean = false, quick_reply: boolean = false): Promise<OB11MessageData[]> {
|
|
const msgSegments = await Promise.allSettled(msg.elements.map(
|
|
async (element): Promise<OB11MessageData | null> => {
|
|
for (const key in element) {
|
|
if (keyCanBeParsed(key, this.rawToOb11Converters) && element[key]) {
|
|
const converters = this.rawToOb11Converters[key] as (
|
|
element: Exclude<MessageElement[keyof RawToOb11Converters], null | undefined>,
|
|
msg: RawMessage,
|
|
elementWrapper: MessageElement,
|
|
context: RecvMessageContext
|
|
) => Promise<OB11MessageData | null>;
|
|
const parsedElement = await converters?.(
|
|
element[key],
|
|
msg,
|
|
element,
|
|
{ parseMultMsg, disableGetUrl, quick_reply }
|
|
);
|
|
if (key === 'faceElement' && !parsedElement) {
|
|
return null;
|
|
}
|
|
return parsedElement;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
));
|
|
|
|
return msgSegments.filter(entry => {
|
|
if (entry.status === 'fulfilled') {
|
|
return !!entry.value;
|
|
} else {
|
|
this.core.context.logger.logError('消息段解析失败', entry.reason);
|
|
return false;
|
|
}
|
|
}).map((entry) => (<PromiseFulfilledResult<OB11MessageData>>entry).value).filter(value => value != null);
|
|
}
|
|
|
|
private async convertArrayToStringMessage (originMsg: OB11Message): Promise<OB11Message> {
|
|
const msg = structuredClone(originMsg);
|
|
msg.message_format = 'string';
|
|
msg.message = msg.raw_message;
|
|
return msg;
|
|
}
|
|
|
|
async importArrayTostringMsg (originMsg: OB11Message) {
|
|
const msg = structuredClone(originMsg);
|
|
msg.message_format = 'string';
|
|
msg.message = msg.raw_message;
|
|
return msg;
|
|
}
|
|
|
|
async createSendElements (
|
|
messageData: OB11MessageData[],
|
|
peer: Peer,
|
|
ignoreTypes: OB11MessageDataType[] = [],
|
|
options: {
|
|
usePacketVideoMetadata?: boolean;
|
|
} = {}
|
|
) {
|
|
const deleteAfterSentFiles: string[] = [];
|
|
const callResultList: Array<Promise<SendMessageElement | undefined>> = [];
|
|
for (const sendMsg of messageData) {
|
|
if (ignoreTypes.includes(sendMsg.type)) {
|
|
continue;
|
|
}
|
|
const converter = this.ob11ToRawConverters[sendMsg.type] as ((
|
|
sendMsg: Extract<OB11MessageData, { type: OB11MessageData['type']; }>,
|
|
context: SendMessageContext,
|
|
) => Promise<SendMessageElement | undefined>) | undefined;
|
|
if (converter === undefined) {
|
|
throw new Error('未知的消息类型:' + sendMsg.type);
|
|
}
|
|
const callResult = converter(
|
|
sendMsg,
|
|
{
|
|
peer,
|
|
deleteAfterSentFiles,
|
|
usePacketVideoMetadata: options.usePacketVideoMetadata,
|
|
}
|
|
)?.catch(undefined);
|
|
callResultList.push(callResult);
|
|
}
|
|
const ret = await Promise.all(callResultList);
|
|
const sendElements: SendMessageElement[] = ret.filter(ele => !!ele);
|
|
return { sendElements, deleteAfterSentFiles };
|
|
}
|
|
|
|
async sendMsgWithOb11UniqueId (peer: Peer, sendElements: SendMessageElement[], deleteAfterSentFiles: string[], timeoutOverride?: number) {
|
|
if (!sendElements.length) {
|
|
throw new Error('消息体无法解析, 请检查是否发送了不支持的消息类型');
|
|
}
|
|
|
|
const timeoutConfig = this.obContext.configLoader.configData.timeout;
|
|
|
|
let timeout: number;
|
|
if (timeoutOverride !== undefined && timeoutOverride > 0) {
|
|
timeout = capTimeout(timeoutConfig, timeoutOverride);
|
|
} else {
|
|
const totalSize = sendElements.reduce((total, element) => {
|
|
switch (element.elementType) {
|
|
case ElementType.PTT:
|
|
return total + (+(element.pttElement.fileSize || '3000'));
|
|
case ElementType.FILE:
|
|
return total + (+(element.fileElement.fileSize || '3000'));
|
|
case ElementType.VIDEO:
|
|
return total + (+(element.videoElement.fileSize || '3000'));
|
|
case ElementType.PIC:
|
|
return total + (+(element.picElement.fileSize || '3000'));
|
|
default:
|
|
return total;
|
|
}
|
|
}, 0);
|
|
|
|
timeout = calculateTimeout(timeoutConfig, totalSize, timeoutConfig.uploadSpeedKBps);
|
|
}
|
|
try {
|
|
const returnMsg = await this.core.apis.MsgApi.sendMsg(peer, sendElements, timeout);
|
|
if (!returnMsg) throw new Error('发送消息失败');
|
|
returnMsg.id = MessageUnique.createUniqueMsgId({
|
|
chatType: peer.chatType,
|
|
guildId: '',
|
|
peerUid: peer.peerUid,
|
|
}, returnMsg.msgId);
|
|
return returnMsg;
|
|
} finally {
|
|
cleanTaskQueue.addFiles(deleteAfterSentFiles, timeout);
|
|
// setTimeout(async () => {
|
|
// const deletePromises = deleteAfterSentFiles.map(async file => {
|
|
// try {
|
|
// if (await fsPromise.access(file, constants.W_OK).then(() => true).catch(() => false)) {
|
|
// await fsPromise.unlink(file);
|
|
// }
|
|
// } catch (e) {
|
|
// this.core.context.logger.logError('发送消息删除文件失败', e);
|
|
// }
|
|
// });
|
|
// await Promise.all(deletePromises);
|
|
// }, 60000);
|
|
}
|
|
}
|
|
|
|
private async handleOb11FileLikeMessage (
|
|
{ data: inputdata }: OB11MessageFileBase,
|
|
{ deleteAfterSentFiles }: SendMessageContext
|
|
) {
|
|
let realUri = [inputdata.url, inputdata.file, inputdata.path].find(uri => uri && uri.trim()) ?? '';
|
|
if (!realUri) {
|
|
this.core.context.logger.logError('文件消息缺少参数', inputdata);
|
|
throw new Error('文件消息缺少参数');
|
|
}
|
|
realUri = await this.handleObfuckName(realUri) ?? realUri;
|
|
try {
|
|
const proxy = this.obContext.configLoader.configData.imageDownloadProxy || undefined;
|
|
const { path, fileName, errMsg, success, isLocal } = await uriToLocalFile(this.core.SnowCatTempPath, realUri, undefined, undefined, proxy);
|
|
if (!success) {
|
|
this.core.context.logger.logError('文件处理失败', errMsg);
|
|
throw new Error('文件处理失败: ' + errMsg);
|
|
}
|
|
if (!isLocal) {
|
|
deleteAfterSentFiles.push(path);
|
|
}
|
|
return { path, fileName: inputdata.name ?? fileName, isLocal };
|
|
} catch (e: unknown) {
|
|
throw new Error((e as Error).message);
|
|
}
|
|
}
|
|
|
|
async handleObfuckName (name: string) {
|
|
const contextMsgFile = FileNapCatOneBotUUID.decode(name);
|
|
if (contextMsgFile && contextMsgFile.msgId && contextMsgFile.elementId) {
|
|
const { peer, msgId, elementId } = contextMsgFile;
|
|
const rawMessage = (await this.core.apis.MsgApi.getMsgsByMsgId(peer, [msgId]))?.msgList.find(msg => msg.msgId === msgId);
|
|
const mixElement = rawMessage?.elements.find(e => e.elementId === elementId);
|
|
const mixElementInner = mixElement?.videoElement ?? mixElement?.fileElement ?? mixElement?.pttElement ?? mixElement?.picElement;
|
|
if (!mixElementInner) throw new Error('element not found');
|
|
let url = '';
|
|
if (mixElement?.picElement && rawMessage) {
|
|
const tempData =
|
|
await this.obContext.apis.MsgApi.rawToOb11Converters.picElement?.(mixElement?.picElement, rawMessage, mixElement, { parseMultMsg: false, disableGetUrl: false, quick_reply: false }) as OB11MessageImage | undefined;
|
|
url = tempData?.data.url ?? '';
|
|
}
|
|
if (mixElement?.videoElement && rawMessage) {
|
|
const tempData =
|
|
await this.obContext.apis.MsgApi.rawToOb11Converters.videoElement?.(mixElement?.videoElement, rawMessage, mixElement, { parseMultMsg: false, disableGetUrl: false, quick_reply: false }) as OB11MessageVideo | undefined;
|
|
url = tempData?.data.url ?? '';
|
|
}
|
|
return url !== '' ? url : await this.core.apis.FileApi.downloadMedia(msgId, peer.chatType, peer.peerUid, elementId, '', '');
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
groupChangDecreseType2String (type: number): GroupDecreaseSubType {
|
|
switch (type) {
|
|
case 130:
|
|
return 'leave';
|
|
case 131:
|
|
return 'kick';
|
|
case 3:
|
|
return 'kick_me';
|
|
case 129:
|
|
return 'disband';
|
|
default:
|
|
return 'kick';
|
|
}
|
|
}
|
|
|
|
async waitGroupNotify (groupUin: string, memberUid?: string, operatorUid?: string) {
|
|
const groupRole = this.core.apis.GroupApi.groupMemberCache.get(groupUin)?.get(this.core.selfInfo.uid.toString())?.role;
|
|
const isAdminOrOwner = groupRole === 3 || groupRole === 4;
|
|
if (isAdminOrOwner && !operatorUid) {
|
|
let dataNotify: GroupNotify | undefined;
|
|
await this.core.eventWrapper.registerListen('NodeIKernelGroupListener/onGroupNotifiesUpdated',
|
|
(_doubt, notifies) => {
|
|
for (const notify of notifies) {
|
|
if (notify.group.groupCode === groupUin && notify.user1.uid === memberUid) {
|
|
dataNotify = notify;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}, 1, 1000).catch(() => undefined);
|
|
if (dataNotify) {
|
|
return !dataNotify.actionUser.uid ? dataNotify.user2.uid : dataNotify.actionUser.uid;
|
|
}
|
|
}
|
|
|
|
return operatorUid;
|
|
}
|
|
|
|
async parseSysMessage (msg: number[]) {
|
|
const SysMessage = new NapProtoMsg(PushMsgBody).decode(Uint8Array.from(msg));
|
|
// 邀请需要解grayTipElement
|
|
// console.log(SysMessage.body?.msgContent);
|
|
if (SysMessage.contentHead.type === 33 && SysMessage.body?.msgContent) {
|
|
const groupChange = new NapProtoMsg(GroupChange).decode(SysMessage.body.msgContent);
|
|
await this.core.apis.GroupApi.refreshGroupMemberCache(groupChange.groupUin.toString(), true);
|
|
const operatorUid = await this.waitGroupNotify(
|
|
groupChange.groupUin.toString(),
|
|
groupChange.memberUid,
|
|
groupChange.operatorInfo ? new TextDecoder('utf-8').decode(groupChange.operatorInfo) : undefined
|
|
);
|
|
return new OB11GroupIncreaseEvent(
|
|
this.core,
|
|
groupChange.groupUin,
|
|
groupChange.memberUid ? +await this.core.apis.UserApi.getUinByUidV2(groupChange.memberUid) : 0,
|
|
operatorUid ? +await this.core.apis.UserApi.getUinByUidV2(operatorUid) : 0,
|
|
groupChange.decreaseType === 131 ? 'invite' : 'approve'
|
|
);
|
|
} else if (SysMessage.contentHead.type === 34 && SysMessage.body?.msgContent) {
|
|
const groupChange = new NapProtoMsg(GroupChange).decode(SysMessage.body.msgContent);
|
|
|
|
let operator_uid_parse: string | undefined;
|
|
if (groupChange.operatorInfo) {
|
|
// 先判断是否可能是protobuf(自身被踢出或以0a开头)
|
|
if (groupChange.decreaseType === 3 || Buffer.from(groupChange.operatorInfo).toString('hex').startsWith('0a')) {
|
|
// 可能是protobuf,尝试解析
|
|
try {
|
|
operator_uid_parse = new NapProtoMsg(GroupChangeInfo).decode(groupChange.operatorInfo).operator?.operatorUid;
|
|
} catch (_error) {
|
|
// protobuf解析失败,fallback到字符串解析
|
|
try {
|
|
const decoded = new TextDecoder('utf-8').decode(groupChange.operatorInfo);
|
|
// 检查是否包含非ASCII字符,如果包含则丢弃
|
|
const isAsciiOnly = [...decoded].every(char => char.charCodeAt(0) >= 32 && char.charCodeAt(0) <= 126);
|
|
operator_uid_parse = isAsciiOnly ? decoded : '';
|
|
} catch (_e2) {
|
|
operator_uid_parse = '';
|
|
}
|
|
}
|
|
} else {
|
|
// 直接进行字符串解析
|
|
try {
|
|
const decoded = new TextDecoder('utf-8').decode(groupChange.operatorInfo);
|
|
// 检查是否包含非ASCII字符,如果包含则丢弃
|
|
const isAsciiOnly = [...decoded].every(char => char.charCodeAt(0) >= 32 && char.charCodeAt(0) <= 126);
|
|
operator_uid_parse = isAsciiOnly ? decoded : '';
|
|
} catch (_e) {
|
|
operator_uid_parse = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
const operatorUid = await this.waitGroupNotify(
|
|
groupChange.groupUin.toString(),
|
|
groupChange.memberUid,
|
|
operator_uid_parse
|
|
);
|
|
if (groupChange.memberUid === this.core.selfInfo.uid) {
|
|
setTimeout(() => {
|
|
this.core.apis.GroupApi.groupMemberCache.delete(groupChange.groupUin.toString());
|
|
}, 5000);
|
|
// 自己被踢了 5S后回收
|
|
} else {
|
|
await this.core.apis.GroupApi.refreshGroupMemberCache(groupChange.groupUin.toString(), true);
|
|
}
|
|
return new OB11GroupDecreaseEvent(
|
|
this.core,
|
|
groupChange.groupUin,
|
|
groupChange.memberUid ? +await this.core.apis.UserApi.getUinByUidV2(groupChange.memberUid) : 0,
|
|
operatorUid ? +await this.core.apis.UserApi.getUinByUidV2(operatorUid) : 0,
|
|
this.groupChangDecreseType2String(groupChange.decreaseType)
|
|
);
|
|
} else if (SysMessage.contentHead.type === 44 && SysMessage.body?.msgContent) {
|
|
const groupAmin = new NapProtoMsg(GroupAdmin).decode(SysMessage.body.msgContent);
|
|
await this.core.apis.GroupApi.refreshGroupMemberCache(groupAmin.groupUin.toString(), true);
|
|
let enabled = false;
|
|
let uid = '';
|
|
if (groupAmin.body.extraEnable != null) {
|
|
uid = groupAmin.body.extraEnable.adminUid;
|
|
enabled = true;
|
|
} else if (groupAmin.body.extraDisable != null) {
|
|
uid = groupAmin.body.extraDisable.adminUid;
|
|
enabled = false;
|
|
}
|
|
return new OB11GroupAdminNoticeEvent(
|
|
this.core,
|
|
groupAmin.groupUin,
|
|
+await this.core.apis.UserApi.getUinByUidV2(uid),
|
|
enabled ? 'set' : 'unset'
|
|
);
|
|
} else if (SysMessage.contentHead.type === 87 && SysMessage.body?.msgContent) {
|
|
const groupInvite = new NapProtoMsg(GroupInvite).decode(SysMessage.body.msgContent);
|
|
let request_seq = '';
|
|
try {
|
|
await this.core.eventWrapper.registerListen('NodeIKernelMsgListener/onRecvMsg', (msgs) => {
|
|
for (const msg of msgs) {
|
|
if (msg.senderUid === groupInvite.invitorUid && msg.msgType === 11) {
|
|
const jumpUrl = JSON.parse(msg.elements.find(e => e.elementType === 10)?.arkElement?.bytesData ?? '').meta?.news?.jumpUrl;
|
|
const jumpUrlParams = new URLSearchParams(jumpUrl);
|
|
const groupcode = jumpUrlParams.get('groupcode');
|
|
const receiveruin = jumpUrlParams.get('receiveruin');
|
|
const msgseq = jumpUrlParams.get('msgseq');
|
|
request_seq = msgseq ?? '';
|
|
if (groupcode === groupInvite.groupUin.toString() && receiveruin === this.core.selfInfo.uin) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}, 1, 1000);
|
|
} catch {
|
|
request_seq = '';
|
|
}
|
|
// 未拉取到seq
|
|
if (request_seq === '') {
|
|
return;
|
|
}
|
|
// 创建个假的
|
|
this.notifyGroupInvite.put(request_seq, {
|
|
seq: request_seq,
|
|
type: 1,
|
|
group: {
|
|
groupCode: groupInvite.groupUin.toString(),
|
|
groupName: '',
|
|
},
|
|
user1: {
|
|
uid: groupInvite.invitorUid,
|
|
nickName: '',
|
|
},
|
|
user2: {
|
|
uid: this.core.selfInfo.uid,
|
|
nickName: '',
|
|
},
|
|
actionUser: {
|
|
uid: groupInvite.invitorUid,
|
|
nickName: '',
|
|
},
|
|
actionTime: Date.now().toString(),
|
|
postscript: '',
|
|
repeatSeqs: [],
|
|
warningTips: '',
|
|
invitationExt: {
|
|
srcType: 1,
|
|
groupCode: groupInvite.groupUin.toString(),
|
|
waitStatus: 1,
|
|
},
|
|
status: 1,
|
|
});
|
|
return new OB11GroupRequestEvent(
|
|
this.core,
|
|
+groupInvite.groupUin,
|
|
+await this.core.apis.UserApi.getUinByUidV2(groupInvite.invitorUid),
|
|
'invite',
|
|
'',
|
|
request_seq
|
|
);
|
|
} else if (SysMessage.contentHead.type === 528 && SysMessage.contentHead.subType === 39 && SysMessage.body?.msgContent) {
|
|
return await this.obContext.apis.UserApi.parseLikeEvent(SysMessage.body?.msgContent);
|
|
} else if (SysMessage.contentHead.type === 166 && SysMessage.contentHead.c2CCmd === 133 && SysMessage.body?.msgContent) {
|
|
this.core.context.logger.logDebug('在线文件通道断开');
|
|
// 可能原因: 对方取消 对方拒绝 对方转离线
|
|
// body不是proto,只能手动提取,可能是错的!!
|
|
// console.log(SysMessage.body?.msgContent);
|
|
const mainCmd = SysMessage.body.msgContent[15];
|
|
const subCmd = SysMessage.body.msgContent[17];
|
|
if (mainCmd === 101) {
|
|
// 在线文件
|
|
if (subCmd === 225) {
|
|
// 对方取消或转离线
|
|
this.core.context.logger.log(`好友:${SysMessage.responseHead.fromUin}取消了在线文件的传输(或转离线)`);
|
|
return new OB11OnlineFileReceiveEvent(
|
|
this.core,
|
|
+SysMessage.responseHead.fromUin
|
|
);
|
|
} else if (subCmd === 230) {
|
|
// 对方拒绝接收
|
|
this.core.context.logger.log(`好友:${SysMessage.responseHead.fromUin}拒绝了你的在线文件传输`);
|
|
return new OB11OnlineFileSendEvent(
|
|
this.core,
|
|
+SysMessage.responseHead.fromUin,
|
|
'refuse'
|
|
);
|
|
}
|
|
} else if (mainCmd === 136) {
|
|
if (subCmd === 225) {
|
|
// 对方取消或转离线
|
|
this.core.context.logger.log(`好友:${SysMessage.responseHead.fromUin}取消了在线文件夹的传输(或转离线)`);
|
|
return new OB11OnlineFileReceiveEvent(
|
|
this.core,
|
|
+SysMessage.responseHead.fromUin
|
|
);
|
|
} else if (subCmd === 230) {
|
|
// 对方拒绝接收
|
|
this.core.context.logger.log(`好友:${SysMessage.responseHead.fromUin}拒绝了你的在线文件夹传输`);
|
|
return new OB11OnlineFileSendEvent(
|
|
this.core,
|
|
+SysMessage.responseHead.fromUin,
|
|
'refuse'
|
|
);
|
|
}
|
|
}
|
|
this.core.context.logger.logDebug('未知的系统消息事件:', mainCmd, subCmd);
|
|
return undefined;
|
|
} else if (SysMessage.contentHead.type === 166 && SysMessage.contentHead.c2CCmd === 131 && SysMessage.body?.msgContent) {
|
|
const mainCmd = SysMessage.body.msgContent[15];
|
|
if (mainCmd === 101) {
|
|
this.core.context.logger.log('在线文件传输成功!');
|
|
} else if (mainCmd === 136) {
|
|
this.core.context.logger.log('在线文件夹传输成功!');
|
|
}
|
|
return new OB11OnlineFileSendEvent(
|
|
this.core,
|
|
+SysMessage.responseHead.fromUin,
|
|
'receive'
|
|
);
|
|
}
|
|
// else if (SysMessage.contentHead.type == 732 && SysMessage.contentHead.subType == 16 && SysMessage.body?.msgContent) {
|
|
// let data_wrap = PBString(2);
|
|
// let user_wrap = PBUint64(5);
|
|
// let group_wrap = PBUint64(4);
|
|
|
|
// ProtoBuf(class extends ProtoBufBase {
|
|
// group = group_wrap;
|
|
// content = ProtoBufIn(5, { data: data_wrap, user: user_wrap });
|
|
// }).decode(SysMessage.body?.msgContent.slice(7));
|
|
// let xml_data = UnWrap(data_wrap);
|
|
// let group = UnWrap(group_wrap).toString();
|
|
// //let user = UnWrap(user_wrap).toString();
|
|
// const parsedParts = this.parseTextWithJson(xml_data);
|
|
// //解析JSON
|
|
// if (parsedParts[1] && parsedParts[3]) {
|
|
// let set_user_id: string = (parsedParts[1].content as { data: string }).data;
|
|
// let uid = await this.core.apis.UserApi.getUidByUinV2(set_user_id);
|
|
// let new_title: string = (parsedParts[3].content as { text: string }).text;
|
|
// console.log(this.core.apis.GroupApi.groupMemberCache.get(group)?.get(uid)?.memberSpecialTitle, new_title)
|
|
// if (this.core.apis.GroupApi.groupMemberCache.get(group)?.get(uid)?.memberSpecialTitle == new_title) {
|
|
// return;
|
|
// }
|
|
// await this.core.apis.GroupApi.refreshGroupMemberCachePartial(group, uid);
|
|
// //let json_data_1_url_search = new URL((parsedParts[3].content as { url: string }).url).searchParams;
|
|
// //let is_new: boolean = json_data_1_url_search.get('isnew') === '1';
|
|
|
|
// //console.log(group, set_user_id, is_new, new_title);
|
|
// return new GroupMemberTitle(
|
|
// this.core,
|
|
// +group,
|
|
// +set_user_id,
|
|
// new_title
|
|
// );
|
|
// }
|
|
// }
|
|
return undefined;
|
|
}
|
|
}
|