@napuketto/adapter 0.0.20 → 0.0.22

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.
@@ -1,7 +1,9 @@
1
1
  import { i as BaseAction, o as ActionRegistry, r as BaseProtocolAdapter, t as ensureSilk } from "../core-of7sLiQX.mjs";
2
- import { i as createWsServerSchema, n as collectGrayTipUids, r as createHttpServerSchema, t as forEachRawMessage } from "../raw-message-DfHdOVHi.mjs";
3
- import { ChatType, GrayTipSubType, GroupApi, GroupNotifyMsgType, NTGroupMemberRole, NTGroupRequestOperateTypes, TicketApi, TipGroupElementType, WebHonorType, kernelError, toCanonicalElements } from "@napuketto/kernel";
2
+ import { i as createWsServerSchema, n as collectGrayTipUids, r as createHttpServerSchema, t as forEachRawMessage } from "../raw-message-Bx18pIQS.mjs";
3
+ import { ChatType, GrayTipSubType, GroupApi, GroupNotifyMsgStatus, GroupNotifyMsgType, NTGroupMemberRole, NTGroupRequestOperateTypes, TicketApi, TipGroupElementType, WebHonorType, kernelError, toCanonicalElements } from "@napuketto/kernel";
4
+ import { downloadUrl, inferExtension } from "@napuketto/media";
4
5
  import { z } from "zod";
6
+ import { randomUUID } from "node:crypto";
5
7
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
6
8
  import { basename, join } from "node:path";
7
9
  import { HttpClient, HttpServer, WsClient, WsServer } from "@napuketto/network";
@@ -1534,6 +1536,10 @@ const CANONICAL_TO_SEGMENT = {
1534
1536
  type: "video",
1535
1537
  data: mediaData(el.path, el.url)
1536
1538
  }),
1539
+ file: (el) => ({
1540
+ type: "file",
1541
+ data: { file: el.name ?? el.path }
1542
+ }),
1537
1543
  reply: (el) => ({
1538
1544
  type: "reply",
1539
1545
  data: { id: el.messageId }
@@ -1624,6 +1630,10 @@ const CQ_SEGMENT_BUILDERS = {
1624
1630
  type: "video",
1625
1631
  data: mediaData(params["file"] ?? "", params["url"])
1626
1632
  }),
1633
+ file: (params) => ({
1634
+ type: "file",
1635
+ data: { file: params["file"] ?? "" }
1636
+ }),
1627
1637
  reply: (params) => ({
1628
1638
  type: "reply",
1629
1639
  data: { id: params["id"] ?? "" }
@@ -1719,6 +1729,12 @@ const ob11ConfigSchema = z.object({
1719
1729
  reportSelfMessage: z.boolean().default(false),
1720
1730
  /** 消息内容格式:array = 消息段数组(标准),string = CQ 码字符串。 */
1721
1731
  messagePostFormat: z.enum(MESSAGE_FORMATS).default("array"),
1732
+ /**
1733
+ * 群文件报形式(B4,2026-09-08):false(缺省)= message 事件 + file 段
1734
+ * (go-cqhttp 兼容;此前 file 元素被静默丢弃,本版本起透出);true = 含
1735
+ * fileElement 的群消息改报 group_upload notice 事件(替代 message 事件)。
1736
+ */
1737
+ groupUploadAsNotice: z.boolean().default(false),
1722
1738
  /** HTTP 反向服务器列表。 */
1723
1739
  httpServers: z.array(httpServerSchema).default([]),
1724
1740
  /** HTTP 正向上报列表。 */
@@ -1853,7 +1869,7 @@ function cqMessageToCanonical(text) {
1853
1869
  //#endregion
1854
1870
  //#region src/onebot11/helper/message-info.ts
1855
1871
  /** 毫秒 → 秒(Unix 时间戳)。 */
1856
- const MS_TO_SEC$3 = 1e3;
1872
+ const MS_TO_SEC$4 = 1e3;
1857
1873
  /** RawMessage → OB11 消息信息(message_id 经 MessageUnique 映射)。
1858
1874
  * ctx(可选):接收方向 ID 转换上下文(at uid→uin、reply NT msgId→OB11 id,P2-19)。 */
1859
1875
  function toOb11MessageInfo(msg, unique, ctx = {}) {
@@ -1870,7 +1886,7 @@ function toOb11MessageInfo(msg, unique, ctx = {}) {
1870
1886
  user_id: Number(msg.senderUin),
1871
1887
  nickname: msg.sendNickName
1872
1888
  },
1873
- time: Math.floor(Number(msg.msgTime) / MS_TO_SEC$3),
1889
+ time: Math.floor(Number(msg.msgTime) / MS_TO_SEC$4),
1874
1890
  message: canonicalToSegments(elements),
1875
1891
  raw_message: canonicalToCqMessage(elements)
1876
1892
  };
@@ -1884,58 +1900,81 @@ function toOb11MessageInfo(msg, unique, ctx = {}) {
1884
1900
  }
1885
1901
  //#endregion
1886
1902
  //#region src/onebot11/helper/notice.ts
1887
- /**
1888
- * OB11 notice 事件翻译(P2-7,2026-08-05)
1889
- *
1890
- * NT QQ 中群成员变动/撤回等系统事件通过**消息的灰色提示元素(grayTip)**广播。
1891
- * 本模块把 RawMessage 里的 grayTip 翻译为 OB11 notice 事件(纯函数,ADR-008)。
1892
- *
1893
- * 支持:
1894
- * - group_recall(撤回,grayTip.subElementType=REVOKE)
1895
- * - group_increase / group_decrease(群成员变动,subElementType=GROUP + TipGroupElement.type)
1896
- * - group_admin(管理员变更,TipGroupElement.type=BLOCK/UNBLOCK 的 role 语义)
1897
- * - group_ban(禁言,TipGroupElement.type=SHUT_UP)
1898
- *
1899
- * user_id/operator_id 都是 uin:接收 uidToUin Map(调用方批量转换后传入,保持纯函数)。
1900
- */
1901
1903
  /** 毫秒 → 秒(Unix 时间戳)。 */
1902
- const MS_TO_SEC$2 = 1e3;
1904
+ const MS_TO_SEC$3 = 1e3;
1905
+ /** 群文件业务类型(group_upload 的 file.busid,与 kernel 群文件系列同源语义)。 */
1906
+ const GROUP_FILE_BUS_ID = 102;
1907
+ /** 检查消息是否含文件元素(group_upload 开关分支判定)。 */
1908
+ function hasFileElement(msg) {
1909
+ return msg.elements.some((el) => el.fileElement !== void 0);
1910
+ }
1903
1911
  /** 检查消息是否含可翻译的 grayTip 元素。 */
1904
1912
  function hasGrayTip(msg) {
1905
1913
  return msg.elements.some((el) => el.grayTipElement !== void 0);
1906
1914
  }
1907
1915
  /** uid → uin(上下文映射缺省退化为 uid)。 */
1908
- function toUin(uid, ctx) {
1916
+ function toUin$1(uid, ctx) {
1909
1917
  const uin = ctx.uidToUin.get(uid);
1910
1918
  return Number(uin ?? uid);
1911
1919
  }
1912
1920
  /** 基础字段(time/self_id/post_type)。 */
1913
1921
  function base(msg, ctx) {
1914
1922
  return {
1915
- time: Math.floor(Number(msg.msgTime) / MS_TO_SEC$2),
1923
+ time: Math.floor(Number(msg.msgTime) / MS_TO_SEC$3),
1916
1924
  self_id: Number(ctx.selfUin),
1917
1925
  post_type: "notice",
1918
1926
  group_id: Number(msg.peerUid)
1919
1927
  };
1920
1928
  }
1921
- /** 撤回事件(group_recall)。 */
1929
+ /** 撤回事件(chatType 分流:GROUP→group_recall / C2C→friend_recall,2026-09-08)。 */
1922
1930
  function toRecall(msg, g, ctx) {
1923
1931
  if (g?.revokeElement === void 0) return null;
1924
1932
  const revoke = g.revokeElement;
1933
+ if (msg.chatType === ChatType.C2C) return {
1934
+ time: Math.floor(Number(msg.msgTime) / MS_TO_SEC$3),
1935
+ self_id: Number(ctx.selfUin),
1936
+ post_type: "notice",
1937
+ notice_type: "friend_recall",
1938
+ user_id: toUin$1(revoke.operatorUid, ctx),
1939
+ message_id: Number(msg.msgSeq)
1940
+ };
1925
1941
  return {
1926
1942
  ...base(msg, ctx),
1927
1943
  notice_type: "group_recall",
1928
- user_id: toUin(revoke.operatorUid, ctx),
1929
- operator_id: toUin(revoke.operatorUid, ctx),
1944
+ user_id: toUin$1(revoke.operatorUid, ctx),
1945
+ operator_id: toUin$1(revoke.operatorUid, ctx),
1930
1946
  message_id: Number(msg.msgSeq)
1931
1947
  };
1932
1948
  }
1949
+ /**
1950
+ * 戳一戳(notify.poke,aioOpGrayTipElement,2026-09-08)。
1951
+ *
1952
+ * ⚠️ 待真实 poke 事件验证:aioOp 载荷的完整字段形状未经实测(实体类型仅有
1953
+ * operateType/peerUid 两个已证字段)。当前口径:user_id = 消息发送者,
1954
+ * target_id = aioOp.peerUid(C2C 即对端;群内为目标 uid 的假设待校准),
1955
+ * group_id = 群号(C2C 为 0)。poke 路径始终打 raw 日志积累校准数据。
1956
+ */
1957
+ function toPoke(msg, g, ctx) {
1958
+ const aioOp = g?.aioOpGrayTipElement;
1959
+ ctx.logger?.warn({ raw: JSON.stringify(g)?.slice(0, 2e3) }, "ob11: poke grayTip raw(待真实事件校准)");
1960
+ const isGroup = msg.chatType === ChatType.GROUP;
1961
+ return {
1962
+ time: Math.floor(Number(msg.msgTime) / MS_TO_SEC$3),
1963
+ self_id: Number(ctx.selfUin),
1964
+ post_type: "notice",
1965
+ notice_type: "notify",
1966
+ sub_type: "poke",
1967
+ group_id: isGroup ? Number(msg.peerUid) : 0,
1968
+ user_id: toUin$1(msg.senderUid, ctx),
1969
+ target_id: toUin$1(aioOp?.peerUid ?? "", ctx)
1970
+ };
1971
+ }
1933
1972
  /** 群成员变动(group_increase / group_decrease / group_admin / group_ban)。 */
1934
1973
  function toGroupChange(msg, g, ctx) {
1935
1974
  const grp = g?.groupElement;
1936
1975
  if (grp === void 0 || grp.type === void 0) return null;
1937
1976
  const common = {
1938
- time: Math.floor(Number(msg.msgTime) / MS_TO_SEC$2),
1977
+ time: Math.floor(Number(msg.msgTime) / MS_TO_SEC$3),
1939
1978
  selfId: Number(ctx.selfUin),
1940
1979
  groupId: Number(msg.peerUid),
1941
1980
  memberUid: grp.memberUid ?? "",
@@ -1960,8 +1999,8 @@ function toMemberAdd(common, ctx) {
1960
1999
  notice_type: "group_increase",
1961
2000
  sub_type: adminUid === memberUid ? "approve" : "invite",
1962
2001
  group_id: groupId,
1963
- operator_id: toUin(adminUid, ctx),
1964
- user_id: toUin(memberUid, ctx)
2002
+ operator_id: toUin$1(adminUid, ctx),
2003
+ user_id: toUin$1(memberUid, ctx)
1965
2004
  };
1966
2005
  }
1967
2006
  /** 群成员退出(leave)。 */
@@ -1974,8 +2013,8 @@ function toQuit(common, ctx) {
1974
2013
  notice_type: "group_decrease",
1975
2014
  sub_type: "leave",
1976
2015
  group_id: groupId,
1977
- operator_id: toUin(adminUid, ctx),
1978
- user_id: toUin(memberUid, ctx)
2016
+ operator_id: toUin$1(adminUid, ctx),
2017
+ user_id: toUin$1(memberUid, ctx)
1979
2018
  };
1980
2019
  }
1981
2020
  /** 管理员变更(BLOCK=set / UNBLOCK=unset)。 */
@@ -1988,7 +2027,7 @@ function toAdminChange(common, grp, ctx) {
1988
2027
  notice_type: "group_admin",
1989
2028
  sub_type: grp.type === TipGroupElementType.BLOCK ? "set" : "unset",
1990
2029
  group_id: groupId,
1991
- user_id: toUin(memberUid === "" ? adminUid : memberUid, ctx)
2030
+ user_id: toUin$1(memberUid === "" ? adminUid : memberUid, ctx)
1992
2031
  };
1993
2032
  }
1994
2033
  /** 禁言(duration>0=ban / 否则 lift_ban)。 */
@@ -2002,29 +2041,167 @@ function toBan(common, grp, ctx) {
2002
2041
  notice_type: "group_ban",
2003
2042
  sub_type: Number(shutUp?.duration ?? 0) > 0 ? "ban" : "lift_ban",
2004
2043
  group_id: groupId,
2005
- operator_id: toUin(shutUp?.admin?.uid ?? adminUid, ctx),
2006
- user_id: toUin(shutUp?.member?.uid ?? memberUid, ctx),
2044
+ operator_id: toUin$1(shutUp?.admin?.uid ?? adminUid, ctx),
2045
+ user_id: toUin$1(shutUp?.member?.uid ?? memberUid, ctx),
2007
2046
  duration: Number(shutUp?.duration ?? 0)
2008
2047
  };
2009
2048
  }
2010
- /** RawMessage → OB11 notice 事件(无 grayTip 返回 null)。 */
2049
+ /**
2050
+ * 好友添加(friend_add,B2,2026-09-08)。
2051
+ * 数据源 = kernel BuddyCache 快照 diff(Buddy/onBuddyListChange 全量快照对比,
2052
+ * 首帧 baseline 不发)——非 grayTip 路径。user_id 取 coreInfo.uin(顶层 uin 兜底)。
2053
+ */
2054
+ function toFriendAdd(entry, selfUin) {
2055
+ const uin = entry.coreInfo?.uin ?? entry.uin ?? "";
2056
+ return {
2057
+ time: Math.floor(Date.now() / MS_TO_SEC$3),
2058
+ self_id: Number(selfUin),
2059
+ post_type: "notice",
2060
+ notice_type: "friend_add",
2061
+ user_id: Number(uin)
2062
+ };
2063
+ }
2064
+ /**
2065
+ * 群文件上传(group_upload,B4,2026-09-08)。
2066
+ * 数据源 = 含 fileElement 的群消息(开关 groupUploadAsNotice=true 时替代
2067
+ * message 事件报出)。file.id 取 fileUuid(缺省文件名)、size 取 fileSize、
2068
+ * busid 固定 102(群文件业务类型,与 kernel GROUP_FILE_BIZ_TYPE 同源语义)。
2069
+ */
2070
+ function toGroupUpload(msg, selfUin) {
2071
+ const file = msg.elements.find((e) => e.fileElement !== void 0)?.fileElement;
2072
+ if (file === void 0) return null;
2073
+ return {
2074
+ time: Math.floor(Number(msg.msgTime) / MS_TO_SEC$3),
2075
+ self_id: Number(selfUin),
2076
+ post_type: "notice",
2077
+ notice_type: "group_upload",
2078
+ group_id: Number(msg.peerUid),
2079
+ user_id: Number(msg.senderUin),
2080
+ file: {
2081
+ id: file.fileUuid ?? file.fileName ?? "",
2082
+ name: file.fileName ?? "",
2083
+ size: Number(file.fileSize ?? 0),
2084
+ busid: GROUP_FILE_BUS_ID
2085
+ }
2086
+ };
2087
+ }
2088
+ /** 单个 grayTip 元素 → notice 事件(null = 不可翻译;未知子类型打 raw 日志)。 */
2089
+ function translateGrayTip(msg, g, ctx) {
2090
+ const subType = g.subElementType;
2091
+ if (subType === GrayTipSubType.REVOKE) return toRecall(msg, g, ctx);
2092
+ if (subType === GrayTipSubType.GROUP) return toGroupChange(msg, g, ctx);
2093
+ if (g.aioOpGrayTipElement !== void 0) return toPoke(msg, g, ctx);
2094
+ ctx.logger?.warn({
2095
+ subType,
2096
+ raw: JSON.stringify(g)?.slice(0, 2e3)
2097
+ }, "ob11: 未翻译的 grayTip 子类型(raw 校准数据)");
2098
+ return null;
2099
+ }
2100
+ /** RawMessage → OB11 notice 事件(无 grayTip 返回 null)。
2101
+ * 未知/未翻译子类型打 raw JSON 日志(校准数据积累,2026-09-08)。 */
2011
2102
  function toOb11NoticeEvent(msg, ctx) {
2012
2103
  for (const el of msg.elements) {
2013
2104
  const g = el.grayTipElement;
2014
2105
  if (g === void 0) continue;
2015
- const subType = g.subElementType;
2016
- if (subType === GrayTipSubType.REVOKE) {
2017
- const recall = toRecall(msg, g, ctx);
2018
- if (recall !== null) return recall;
2019
- }
2020
- if (subType === GrayTipSubType.GROUP) {
2021
- const change = toGroupChange(msg, g, ctx);
2022
- if (change !== null) return change;
2023
- }
2106
+ const event = translateGrayTip(msg, g, ctx);
2107
+ if (event !== null) return event;
2024
2108
  }
2025
2109
  return null;
2026
2110
  }
2027
2111
  //#endregion
2112
+ //#region src/onebot11/helper/request.ts
2113
+ /**
2114
+ * OB11 request 事件翻译(2026-09-08)
2115
+ *
2116
+ * 事件源(kernel 事件通道,本次接线):
2117
+ * - `Group/onGroupNotifiesUpdated`(GroupBridge,群系统通知推送)→ group_add / group_invite
2118
+ * - `Buddy/onBuddyReqChange`(FriendBridge,好友申请推送)→ friend
2119
+ *
2120
+ * flag 语义(与应答动作匹配路径一致):
2121
+ * - 群请求 flag = GroupNotify.seq(set_group_add_request 按 `item.seq === flag` 匹配)
2122
+ * - 好友请求 flag = BuddyReq.reqTime(set_friend_add_request 按 `reqTime === flag` 匹配)
2123
+ *
2124
+ * 纯函数(ADR-008):uid→uin 由调用方批量转换后传入。
2125
+ * ⚠️ onBuddyReqChange 回调参数形状未经真实事件校准(wrapper 字符串证据仅有方法名),
2126
+ * narrowBuddyReqs 防御性收窄,未知形状返回 null 由调用方打 raw 日志积累校准数据。
2127
+ */
2128
+ /** 毫秒 → 秒(Unix 时间戳)。 */
2129
+ const MS_TO_SEC$2 = 1e3;
2130
+ /** uid → uin(上下文映射缺省时:数字 uid 直取数值,非数字 uid 返回 0 表示未知)。 */
2131
+ function toUin(uid, ctx) {
2132
+ if (uid === void 0 || uid === "") return 0;
2133
+ const uin = ctx.uidToUin.get(uid);
2134
+ if (uin !== void 0) return Number(uin);
2135
+ const n = Number(uid);
2136
+ return Number.isFinite(n) ? n : 0;
2137
+ }
2138
+ /**
2139
+ * GroupNotify → OB11 群请求事件(group_add / group_invite)。
2140
+ *
2141
+ * type 语义与 get_group_system_msg 动作同一解释:1/5=邀请(invite)、7=申请(add);
2142
+ * 仅未处理状态(KUNHANDLE)推送——已处理通知重复推送不再发事件。
2143
+ * 非请求类通知(踢人/转让等处理结果回执)返回 null。
2144
+ */
2145
+ function toOb11GroupRequestEvent(notify, ctx) {
2146
+ if (notify.status !== GroupNotifyMsgStatus.KUNHANDLE) return null;
2147
+ let subType;
2148
+ switch (notify.type) {
2149
+ case GroupNotifyMsgType.INVITED_BY_MEMBER:
2150
+ case GroupNotifyMsgType.INVITED_NEED_ADMINI_STRATOR_PASS:
2151
+ subType = "invite";
2152
+ break;
2153
+ case GroupNotifyMsgType.REQUEST_JOIN_NEED_ADMINI_STRATOR_PASS:
2154
+ subType = "add";
2155
+ break;
2156
+ default: return null;
2157
+ }
2158
+ return {
2159
+ time: Math.floor(Date.now() / MS_TO_SEC$2),
2160
+ self_id: Number(ctx.selfUin),
2161
+ post_type: "request",
2162
+ request_type: "group",
2163
+ sub_type: subType,
2164
+ group_id: Number(notify.group?.groupCode ?? 0),
2165
+ user_id: toUin(notify.user1?.uid, ctx),
2166
+ comment: notify.postscript ?? "",
2167
+ flag: String(notify.seq)
2168
+ };
2169
+ }
2170
+ /** BuddyReq 防御性收窄:仅接受字段形状可用的对象(onBuddyReqChange 参数待真实事件校准)。 */
2171
+ function isBuddyReqLike(value) {
2172
+ if (typeof value !== "object" || value === null) return false;
2173
+ const v = value;
2174
+ return typeof v["reqTime"] === "string" && typeof v["friendUid"] === "string";
2175
+ }
2176
+ /**
2177
+ * onBuddyReqChange 回调参数 → BuddyReq 列表(防御性收窄)。
2178
+ * 已知候选形状:BuddyReq[] / { buddyReqs: BuddyReq[] };其余返回 null(调用方打 raw 日志)。
2179
+ */
2180
+ function narrowBuddyReqs(arg) {
2181
+ if (Array.isArray(arg)) return arg.every(isBuddyReqLike) ? arg : null;
2182
+ if (typeof arg === "object" && arg !== null) {
2183
+ const reqs = arg["buddyReqs"];
2184
+ if (Array.isArray(reqs) && reqs.every(isBuddyReqLike)) return reqs;
2185
+ }
2186
+ return null;
2187
+ }
2188
+ /**
2189
+ * BuddyReq → OB11 好友请求事件(friend)。
2190
+ * comment 取 words 字段(验证留言,形状待校准,缺省空串);flag = reqTime。
2191
+ */
2192
+ function toOb11FriendRequestEvent(req, ctx) {
2193
+ const words = req["words"];
2194
+ return {
2195
+ time: Math.floor(Date.now() / MS_TO_SEC$2),
2196
+ self_id: Number(ctx.selfUin),
2197
+ post_type: "request",
2198
+ request_type: "friend",
2199
+ user_id: toUin(req.friendUid, ctx),
2200
+ comment: typeof words === "string" ? words : "",
2201
+ flag: req.reqTime
2202
+ };
2203
+ }
2204
+ //#endregion
2028
2205
  //#region src/onebot11/action/message/get-forward-msg.ts
2029
2206
  /**
2030
2207
  * get_forward_msg 动作:获取合并转发内容(P2-12 接 kernel MsgApi.fetchForwardMessage)
@@ -2156,10 +2333,17 @@ var GetGroupMsgHistoryAction = class extends BaseAction {
2156
2333
  //#endregion
2157
2334
  //#region src/onebot11/action/message/get-media.ts
2158
2335
  /**
2159
- * get_image / get_record 动作:获取图片/语音文件信息(P2-14,简化版)
2336
+ * get_image / get_record 动作:获取图片/语音文件(P2-14;2026-09-08 T5 接主动下载;
2337
+ * 2026-09-08 B1 语音接原生 downloadRichMedia)
2160
2338
  *
2161
- * message_id 反查 → fetchMsgsByMsgId → 找 PIC/PTT 元素 → 返回元素已有路径与 URL。
2162
- * 不做主动下载/转码(NapCat 走 downloadRichMedia 事件驱动下载,待后续接入)。
2339
+ * message_id 反查 → fetchMsgsByMsgId → 找 PIC/PTT 元素:
2340
+ * - 本地文件解析:NT 相对路径(sourcePath/filePath)按 mediaBaseDir(QQ NT
2341
+ * global 目录)解析为绝对路径,命中磁盘即返回 file
2342
+ * - 图片主动下载:本地未命中且有 picUrl → @napuketto/media downloadUrl 落
2343
+ * cacheDir/media/,返回 file(绝对路径)+ url + file_size/file_name
2344
+ * - 语音主动下载(B1):本地未命中 → kernel MsgApi.downloadPtt(原生
2345
+ * downloadRichMedia + 轮询元素回查)→ filePath 落盘后返回绝对路径;
2346
+ * 失败回退原始 filePath(NT 相对/绝对)+ 元数据,不抛错
2163
2347
  */
2164
2348
  const getMediaSchema = z.object({
2165
2349
  /** CQ 码 file 字段或 message_id(简化:message_id 反查)。 */
@@ -2167,6 +2351,35 @@ const getMediaSchema = z.object({
2167
2351
  message_id: z.union([z.number(), z.string()]).optional()
2168
2352
  });
2169
2353
  /**
2354
+ * NT 相对路径 → 本地绝对路径(存在才返回)。
2355
+ * 候选:原样绝对路径 / mediaBaseDir 下相对路径(反斜杠归一化)。
2356
+ */
2357
+ function resolveLocalMediaFile(relPath, deps) {
2358
+ if (relPath === "") return null;
2359
+ if (existsSync(relPath)) return relPath;
2360
+ const base = deps.mediaBaseDir;
2361
+ if (base !== void 0 && base !== "") {
2362
+ const joined = join(base, relPath.replaceAll("\\", "/"));
2363
+ if (existsSync(joined)) return joined;
2364
+ }
2365
+ return null;
2366
+ }
2367
+ /** 图片主动下载(picUrl → cacheDir/media/<uuid>.<ext>;无 cacheDir/url 返回 null)。 */
2368
+ async function downloadImageToCache(url, deps) {
2369
+ const cacheDir = deps.cacheDir;
2370
+ if (cacheDir === void 0 || cacheDir === "") return null;
2371
+ const mediaDir = join(cacheDir, "media");
2372
+ const { mkdir } = await import("node:fs/promises");
2373
+ await mkdir(mediaDir, { recursive: true });
2374
+ const dest = join(mediaDir, `${randomUUID()}${inferExtension(null, url)}`);
2375
+ try {
2376
+ await downloadUrl(url, dest);
2377
+ return dest;
2378
+ } catch {
2379
+ return null;
2380
+ }
2381
+ }
2382
+ /**
2170
2383
  * 反查消息并提取媒体元素路径。
2171
2384
  * 返回 null = 消息不存在;extract 返回 undefined = 无匹配元素。
2172
2385
  */
@@ -2180,26 +2393,13 @@ async function resolveMedia(id, label, deps, extract) {
2180
2393
  }
2181
2394
  throw kernelError(`消息 ${id} 不包含${label}`, "NOT_FOUND");
2182
2395
  }
2183
- /** 从消息元素提取媒体路径与 URL(PIC/PTT,纯函数)。 */
2184
- function extractMediaPath(element) {
2185
- const pic = element.picElement;
2186
- if (pic !== void 0) {
2187
- const out = {};
2188
- const path = pic.sourcePath ?? pic.picPath;
2189
- if (path !== void 0) out.file = path;
2190
- if (pic.picUrl !== void 0) out.url = pic.picUrl;
2191
- if (Object.keys(out).length === 0) return;
2192
- return out;
2193
- }
2194
- const ptt = element.pttElement;
2195
- if (ptt !== void 0 && ptt.filePath !== void 0) return { file: ptt.filePath };
2196
- }
2197
- /** 提取语音元素(get_record 用)。 */
2198
- function extractPttPath(element) {
2199
- const ptt = element.pttElement;
2200
- if (ptt !== void 0 && ptt.filePath !== void 0) return { file: ptt.filePath };
2396
+ /** 取 id 参数(message_id 优先,file 兜底)。 */
2397
+ function takeId(payload, actionName) {
2398
+ const id = payload.message_id ?? payload.file;
2399
+ if (id === void 0) throw kernelError(`${actionName} 需要 message_id 或 file`, "INVALID_PARAM");
2400
+ return id;
2201
2401
  }
2202
- /** 获取图片信息(P2-14)。 */
2402
+ /** 获取图片信息(本地优先,picUrl 主动下载兜底)。 */
2203
2403
  var GetImageAction = class extends BaseAction {
2204
2404
  name = "get_image";
2205
2405
  schema = getMediaSchema;
@@ -2209,16 +2409,29 @@ var GetImageAction = class extends BaseAction {
2209
2409
  super();
2210
2410
  this.deps = deps;
2211
2411
  }
2212
- _handle(payload) {
2213
- return this.resolve(payload, "图片", "get_image");
2214
- }
2215
- resolve(payload, label, actionName) {
2216
- const id = payload.message_id ?? payload.file;
2217
- if (id === void 0) throw kernelError(`${actionName} 需要 message_id 或 file`, "INVALID_PARAM");
2218
- return resolveMedia(id, label, this.deps, extractMediaPath);
2412
+ async _handle(payload) {
2413
+ const base = await resolveMedia(takeId(payload, "get_image"), "图片", this.deps, (el) => {
2414
+ const pic = el.picElement;
2415
+ if (pic === void 0) return;
2416
+ const out = {};
2417
+ if (pic.fileSize !== void 0) out.file_size = pic.fileSize;
2418
+ if (pic.fileName !== void 0) out.file_name = pic.fileName;
2419
+ if (pic.picUrl !== void 0) out.url = pic.picUrl;
2420
+ const rel = pic.sourcePath ?? pic.picPath;
2421
+ if (rel !== void 0) {
2422
+ const local = resolveLocalMediaFile(rel, this.deps);
2423
+ if (local !== null) out.file = local;
2424
+ }
2425
+ return out;
2426
+ });
2427
+ if (base.file === void 0 && base.url !== void 0) {
2428
+ const downloaded = await downloadImageToCache(base.url, this.deps);
2429
+ if (downloaded !== null) base.file = downloaded;
2430
+ }
2431
+ return base;
2219
2432
  }
2220
2433
  };
2221
- /** 获取语音信息(P2-14)。 */
2434
+ /** 获取语音信息(本地优先;未命中走原生 downloadRichMedia,失败回退原始路径)。 */
2222
2435
  var GetRecordAction = class extends BaseAction {
2223
2436
  name = "get_record";
2224
2437
  schema = getMediaSchema;
@@ -2229,9 +2442,37 @@ var GetRecordAction = class extends BaseAction {
2229
2442
  this.deps = deps;
2230
2443
  }
2231
2444
  async _handle(payload) {
2232
- const id = payload.message_id ?? payload.file;
2233
- if (id === void 0) throw kernelError("get_record 需要 message_id 或 file", "INVALID_PARAM");
2234
- return await resolveMedia(id, "语音", this.deps, extractPttPath);
2445
+ const id = takeId(payload, "get_record");
2446
+ const { msgId, peer } = resolveMsgIdAndPeer(id, this.deps.messageUnique);
2447
+ const [first] = await this.deps.msgApi.fetchMsgsByMsgId(peer, [msgId]);
2448
+ if (first === void 0) throw kernelError(`消息 ${id} 不存在或已被撤回`, "NOT_FOUND");
2449
+ const ptt = first.elements.find((e) => e.pttElement !== void 0)?.pttElement;
2450
+ if (ptt === void 0 || ptt.filePath === void 0) throw kernelError(`消息 ${id} 不包含语音`, "NOT_FOUND");
2451
+ const meta = () => ({
2452
+ ...ptt.fileSize !== void 0 ? { file_size: ptt.fileSize } : {},
2453
+ ...ptt.fileName !== void 0 ? { file_name: ptt.fileName } : {}
2454
+ });
2455
+ const local = resolveLocalMediaFile(ptt.filePath, this.deps);
2456
+ if (local !== null) return {
2457
+ ...meta(),
2458
+ file: local
2459
+ };
2460
+ try {
2461
+ const downloaded = await this.deps.msgApi.downloadPtt(msgId, peer);
2462
+ const rel = downloaded.filePath;
2463
+ if (rel !== void 0 && rel !== "") {
2464
+ const localAfter = resolveLocalMediaFile(rel, this.deps);
2465
+ if (localAfter !== null) return {
2466
+ ...downloaded.fileSize !== void 0 ? { file_size: downloaded.fileSize } : {},
2467
+ ...downloaded.fileName !== void 0 ? { file_name: downloaded.fileName } : {},
2468
+ file: localAfter
2469
+ };
2470
+ }
2471
+ } catch {}
2472
+ return {
2473
+ ...meta(),
2474
+ file: ptt.filePath
2475
+ };
2235
2476
  }
2236
2477
  };
2237
2478
  //#endregion
@@ -3287,6 +3528,8 @@ var OneBotApi = class {
3287
3528
  cleanCache;
3288
3529
  /** 缓存目录(download_file 用)。 */
3289
3530
  cacheDir;
3531
+ /** QQ NT global 目录(媒体 NT 相对路径解析用)。 */
3532
+ mediaBaseDir;
3290
3533
  /** 进程退出回调(bot_exit 用)。 */
3291
3534
  exit;
3292
3535
  /** 进程重启回调(set_restart 用)。 */
@@ -3308,6 +3551,7 @@ var OneBotApi = class {
3308
3551
  this.appVersion = opts.system.appVersion;
3309
3552
  this.cleanCache = opts.system.cleanCache;
3310
3553
  this.cacheDir = opts.system.cacheDir;
3554
+ this.mediaBaseDir = opts.system.mediaBaseDir;
3311
3555
  this.exit = opts.system.exit;
3312
3556
  this.restart = opts.system.restart;
3313
3557
  }
@@ -3391,10 +3635,13 @@ function toOb11MessageEvent(msg, selfUin, unique, messageFormat = "array", ctx =
3391
3635
  */
3392
3636
  /** Authorization 头前缀。 */
3393
3637
  const BEARER_PREFIX = "Bearer ";
3394
- /** 校验 URL 查询参数里的 access_token(WS client 连接 URL 可能带)。 */
3638
+ /** 校验 URL 查询参数里的 access_token(WS client 连接 URL 可能带)。
3639
+ * ⚠️ req.url 是纯路径(无 origin),new URL(相对路径) 必抛——2026-09-08
3640
+ * T10 E2E 实测抓到的 bug(查询参数鉴权从未生效,WS 全被 4401 关闭),
3641
+ * 用 dummy base 解析。 */
3395
3642
  function hasAccessTokenQuery(url, token) {
3396
3643
  try {
3397
- return new URL(url).searchParams.get("access_token") === token;
3644
+ return new URL(url, "http://localhost").searchParams.get("access_token") === token;
3398
3645
  } catch {
3399
3646
  return false;
3400
3647
  }
@@ -3525,15 +3772,23 @@ const MS_TO_SEC = 1e3;
3525
3772
  var NapukettoOneBot11Adapter = class extends BaseProtocolAdapter {
3526
3773
  protocol = "onebot11";
3527
3774
  msgChannel;
3775
+ groupChannel;
3776
+ friendChannel;
3777
+ buddyCacheEvents;
3778
+ calibLogger;
3528
3779
  selfUin;
3529
3780
  oneBotApi;
3530
3781
  /** 动作注册表(公共只读:IPC 桥等装配方枚举动作名直接挂载)。 */
3531
3782
  registry;
3532
- unsubscribe = null;
3783
+ unsubscribes = [];
3533
3784
  transports = null;
3534
3785
  heartbeatTimer = null;
3535
3786
  reportSelfMessage = false;
3536
3787
  messageFormat = "array";
3788
+ /** 群文件报形式(B4):true = fileElement 群消息报 group_upload notice 替代 message。 */
3789
+ groupUploadAsNotice = false;
3790
+ /** IPC 桥模式标记(subscribeOnly 启动;reload 时跳过传输重建)。 */
3791
+ subscribedOnly = false;
3537
3792
  constructor(opts) {
3538
3793
  super({
3539
3794
  config: opts.config,
@@ -3541,20 +3796,38 @@ var NapukettoOneBot11Adapter = class extends BaseProtocolAdapter {
3541
3796
  hooks: {
3542
3797
  onStart: (config) => this.startTransports(config),
3543
3798
  onStop: () => this.stopAll(),
3544
- onReload: () => {
3545
- return Promise.resolve();
3546
- }
3799
+ onReload: (config) => this.reloadTransports(config)
3547
3800
  }
3548
3801
  });
3549
3802
  this.msgChannel = opts.msgChannel;
3803
+ this.groupChannel = opts.groupChannel;
3804
+ this.friendChannel = opts.friendChannel;
3805
+ this.buddyCacheEvents = opts.buddyCacheEvents;
3806
+ this.calibLogger = opts.logger;
3550
3807
  this.selfUin = opts.self.uin;
3551
3808
  this.oneBotApi = new OneBotApi(opts);
3552
3809
  this.registry = createOb11ActionRegistry({ api: this.oneBotApi });
3553
3810
  }
3811
+ /**
3812
+ * 配置热更新(P2-6,2026-09-08 实现):stop 旧传输/心跳/退订 → 按新配置重建。
3813
+ * IPC 桥模式(subscribeOnly,无传输)只刷新上报开关字段,不重建。
3814
+ */
3815
+ async reloadTransports(config) {
3816
+ if (this.subscribedOnly) {
3817
+ this.reportSelfMessage = config.reportSelfMessage;
3818
+ this.messageFormat = config.messagePostFormat;
3819
+ this.groupUploadAsNotice = config.groupUploadAsNotice;
3820
+ return;
3821
+ }
3822
+ await this.stopAll();
3823
+ await this.startTransports(config);
3824
+ }
3554
3825
  /** 启动传输:装配(HTTP/WS)+ 打开 server/client + 广播 lifecycle enable + 起心跳。 */
3555
3826
  async startTransports(config) {
3827
+ this.subscribedOnly = false;
3556
3828
  this.reportSelfMessage = config.reportSelfMessage;
3557
3829
  this.messageFormat = config.messagePostFormat;
3830
+ this.groupUploadAsNotice = config.groupUploadAsNotice;
3558
3831
  const broadcaster = this.getBroadcaster();
3559
3832
  if (broadcaster !== void 0) {
3560
3833
  this.transports = assembleOb11Transports({
@@ -3616,19 +3889,46 @@ var NapukettoOneBot11Adapter = class extends BaseProtocolAdapter {
3616
3889
  this.transports = null;
3617
3890
  }
3618
3891
  }
3619
- /** 订阅 kernel 消息事件(幂等)。 */
3892
+ /** 订阅 kernel 事件(消息 + 群通知 + 好友请求,幂等)。 */
3620
3893
  subscribe() {
3621
- if (this.unsubscribe !== null) return;
3622
- this.unsubscribe = this.msgChannel.on("Msg/onRecvMsg", (msgs) => {
3894
+ if (this.unsubscribes.length > 0) return;
3895
+ this.unsubscribes.push(this.msgChannel.on("Msg/onRecvMsg", (msgs) => {
3623
3896
  forEachRawMessage(msgs, (msg) => {
3624
3897
  if (hasGrayTip(msg)) {
3625
3898
  this.broadcastNotice(msg).catch(() => {});
3626
3899
  return;
3627
3900
  }
3628
3901
  if (!this.reportSelfMessage && String(msg.senderUin) === this.selfUin) return;
3902
+ if (this.groupUploadAsNotice && msg.chatType === ChatType.GROUP && hasFileElement(msg)) {
3903
+ const notice = toGroupUpload(msg, this.selfUin);
3904
+ if (notice !== null) {
3905
+ this.broadcastEvent(notice);
3906
+ return;
3907
+ }
3908
+ }
3629
3909
  this.broadcastMessageEvent(msg);
3630
3910
  });
3631
- });
3911
+ }));
3912
+ if (this.groupChannel !== void 0) this.unsubscribes.push(this.groupChannel.on("Group/onGroupNotifiesUpdated", (doubt, notifies) => {
3913
+ if (doubt || !Array.isArray(notifies)) return;
3914
+ this.broadcastGroupRequests(notifies);
3915
+ }));
3916
+ if (this.friendChannel !== void 0) {
3917
+ this.unsubscribes.push(this.friendChannel.on("Buddy/onBuddyReqChange", (arg) => {
3918
+ const reqs = narrowBuddyReqs(arg);
3919
+ if (reqs === null) {
3920
+ this.calibLogger?.warn({ raw: JSON.stringify(arg)?.slice(0, 2e3) }, "ob11: onBuddyReqChange 未知参数形状");
3921
+ return;
3922
+ }
3923
+ this.broadcastFriendRequests(reqs);
3924
+ }));
3925
+ for (const evt of ["Buddy/onBuddyListChange", "Buddy/onBuddyListChangedV2"]) this.unsubscribes.push(this.friendChannel.on(evt, (arg) => {
3926
+ this.calibLogger?.info({ raw: JSON.stringify(arg)?.slice(0, 2e3) }, `ob11: ${evt} raw(friend_add 校准数据)`);
3927
+ }));
3928
+ }
3929
+ if (this.buddyCacheEvents !== void 0) this.unsubscribes.push(this.buddyCacheEvents.on("BuddyCache/onBuddyAdded", (entry) => {
3930
+ this.broadcastEvent(toFriendAdd(entry, this.selfUin));
3931
+ }));
3632
3932
  }
3633
3933
  /**
3634
3934
  * 仅启动接收链路(IPC 桥模式,2026-08-27):订阅消息通道维护 messageUnique +
@@ -3639,10 +3939,13 @@ var NapukettoOneBot11Adapter = class extends BaseProtocolAdapter {
3639
3939
  const config = await this.config.load();
3640
3940
  this.reportSelfMessage = config.reportSelfMessage;
3641
3941
  this.messageFormat = config.messagePostFormat;
3942
+ this.groupUploadAsNotice = config.groupUploadAsNotice;
3943
+ this.subscribedOnly = true;
3642
3944
  this.subscribe();
3643
3945
  }
3644
3946
  /** 仅退订(与 subscribeOnly 配对的进程级清理;传输关闭仍走 stop())。 */
3645
3947
  unsubscribeOnly() {
3948
+ this.subscribedOnly = false;
3646
3949
  this.unsubscribeAll();
3647
3950
  }
3648
3951
  /**
@@ -3669,21 +3972,57 @@ var NapukettoOneBot11Adapter = class extends BaseProtocolAdapter {
3669
3972
  if (uids.length > 0) uidToUin = await this.oneBotApi.uidToUin(uids);
3670
3973
  const notice = toOb11NoticeEvent(msg, {
3671
3974
  selfUin: this.selfUin,
3672
- uidToUin
3975
+ uidToUin,
3976
+ ...this.calibLogger !== void 0 ? { logger: this.calibLogger } : {}
3673
3977
  });
3674
3978
  if (notice !== null) this.broadcastEvent(notice);
3675
3979
  }
3980
+ /** 广播群系统通知 → OB11 group request 事件(批量 uidToUin 后翻译,纯函数)。 */
3981
+ async broadcastGroupRequests(notifies) {
3982
+ const uids = [];
3983
+ for (const n of notifies) {
3984
+ const notify = n;
3985
+ for (const u of [notify.user1, notify.user2]) if (typeof u?.uid === "string" && u.uid !== "") uids.push(u.uid);
3986
+ }
3987
+ let uidToUin = /* @__PURE__ */ new Map();
3988
+ if (uids.length > 0) try {
3989
+ uidToUin = await this.oneBotApi.uidToUin(uids);
3990
+ } catch {}
3991
+ const ctx = {
3992
+ selfUin: this.selfUin,
3993
+ uidToUin
3994
+ };
3995
+ for (const n of notifies) {
3996
+ const event = toOb11GroupRequestEvent(n, ctx);
3997
+ if (event !== null) this.broadcastEvent(event);
3998
+ }
3999
+ }
4000
+ /** 广播好友申请 → OB11 friend request 事件(批量 uidToUin 后翻译,纯函数)。 */
4001
+ async broadcastFriendRequests(reqs) {
4002
+ const uids = reqs.map((r) => r.friendUid).filter((uid) => uid !== "");
4003
+ let uidToUin = /* @__PURE__ */ new Map();
4004
+ if (uids.length > 0) try {
4005
+ uidToUin = await this.oneBotApi.uidToUin(uids);
4006
+ } catch {}
4007
+ const ctx = {
4008
+ selfUin: this.selfUin,
4009
+ uidToUin
4010
+ };
4011
+ for (const req of reqs) this.broadcastEvent(toOb11FriendRequestEvent(req, ctx));
4012
+ }
3676
4013
  /** 退订(幂等)。 */
3677
4014
  unsubscribeAll() {
3678
- this.unsubscribe?.();
3679
- this.unsubscribe = null;
4015
+ for (const off of this.unsubscribes) off();
4016
+ this.unsubscribes = [];
3680
4017
  }
3681
4018
  /**
3682
4019
  * 请求分发(挂到 network transport 的 onRequest):
3683
4020
  * OB11 标准请求 { action, params, echo } → 动作注册表 → handle(HTTP)/websocketHandle(WS)。
3684
4021
  */
3685
4022
  async handleRequest(req, respond) {
3686
- const { action: rawAction, params, echo } = req ?? {};
4023
+ const parsed = req ?? {};
4024
+ const { action: rawAction, echo } = parsed;
4025
+ const params = parsed.params ?? {};
3687
4026
  let action = "";
3688
4027
  if (typeof rawAction === "string") action = rawAction;
3689
4028
  if (action === "") {