dsh-qq-moony 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/client.js +64 -2
  3. package/index.js +106 -20
  4. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.9.0 — 2026-10-25
4
+
5
+ 一句一句地说,额度按"轮"算。
6
+
7
+ - **拆条发送**:`qq_send` 收到一段话会按句末标点拆开、逐条发,中间隔 1–3 秒。太短的("哦。""!")会并进上一句;一轮最多 `maxMessagesPerRound` 条(默认 4),多的并到最后一条。
8
+ - **额度改按轮计**:`perHour` / `perDay` 现在数的是"轮",一轮只占一份——人本来也不是按条说话的。
9
+ - **技术限速不松**:`perMinute`(默认 6)永远按真实条数算,它是防封的兜底,不跟"轮"妥协。
10
+ - **主动说话改短**:提示词明写"一句就够,短到一个词、一个感叹号、一个颜文字都行",并且"别每次都同一句"。
11
+ - 设置页新增「一句一句说」卡片:开关 + 一轮最多几条 + 每分钟上限。
12
+ ## 0.8.1 — 2026-10-25
13
+
14
+ - **新增表情库上限** `stickerLimit`(默认 200,设置页可改)。超了淘汰最旧的记录,原图文件不删——库要干净,硬盘不用替它心疼。
3
15
  ## 0.8.0 — 2026-10-25
4
16
 
5
17
  表情包会自己攒、自己配了;上限多了一个"不限"。
package/client.js CHANGED
@@ -109,7 +109,10 @@ window.__ModuleLoader__.load({
109
109
  qzoneMinMin: 状态?.qzoneMinMin ?? 120,
110
110
  qzoneMinMax: 状态?.qzoneMinMax ?? 240,
111
111
  qzonePrompt: 状态?.qzonePrompt ?? '',
112
- persona: 状态?.persona ?? '',
112
+ persona: 状态?.persona ?? '',
113
+ stickerLimit: 状态?.stickerLimit ?? 200,
114
+ maxPerRound: 状态?.maxPerRound ?? 4,
115
+ perMinute: 状态?.perMinute ?? 6,
113
116
  groupWhitelist: (状态?.groupWhitelist ?? []).join(' '),
114
117
  groupContextSize: 状态?.groupContextSize ?? 30,
115
118
  groupEveryMinMin: 状态?.groupEveryMinMin ?? 8,
@@ -140,7 +143,10 @@ window.__ModuleLoader__.load({
140
143
  qzoneMinMin: Number(值.qzoneMinMin),
141
144
  qzoneMinMax: Number(值.qzoneMinMax),
142
145
  qzonePrompt: String(值.qzonePrompt ?? ''),
143
- persona: String(值.persona ?? ''),
146
+ persona: String(值.persona ?? ''),
147
+ stickerLimit: Number(值.stickerLimit),
148
+ maxPerRound: Number(值.maxPerRound),
149
+ perMinute: Number(值.perMinute),
144
150
  groupWhitelist: String(值.groupWhitelist ?? ''),
145
151
  groupContextSize: Number(值.groupContextSize),
146
152
  groupEveryMinMin: Number(值.groupEveryMinMin),
@@ -471,6 +477,44 @@ window.__ModuleLoader__.load({
471
477
  ),
472
478
  h(开关, { 开: 状态.qzoneAuto, 点: () => 改({ qzoneAuto: !状态.qzoneAuto }) }),
473
479
  ),
480
+ h(
481
+ 'div',
482
+ { style: { ...卡片, alignItems: 'flex-start', flexDirection: 'column', gap: '8px' } },
483
+ h(
484
+ 'div',
485
+ { style: { display: 'flex', width: '100%', alignItems: 'center', justifyContent: 'space-between', gap: '16px' } },
486
+ h(
487
+ 'div',
488
+ null,
489
+ h('div', { style: 标题 }, '一句一句说'),
490
+ h('div', { style: 说明 }, '一段话按句拆成几条发,中间隔一两秒;一轮最多几条、每分钟硬上限都在下面'),
491
+ ),
492
+ h(开关, { 开: 状态.splitReply, 点: () => 改({ splitReply: !状态.splitReply }) }),
493
+ ),
494
+ h(
495
+ 'div',
496
+ { style: { display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' } },
497
+ h('span', { style: 说明 }, '一轮最多'),
498
+ h('input', {
499
+ type: 'number',
500
+ min: 1,
501
+ max: 8,
502
+ value: 值.maxPerRound,
503
+ onChange: (e) => 数字('maxPerRound', Number(e.target.value)),
504
+ style: { ...输入框, width: '70px' },
505
+ }),
506
+ h('span', { style: 说明 }, '条 · 每分钟最多'),
507
+ h('input', {
508
+ type: 'number',
509
+ min: 1,
510
+ max: 60,
511
+ value: 值.perMinute,
512
+ onChange: (e) => 数字('perMinute', Number(e.target.value)),
513
+ style: { ...输入框, width: '70px' },
514
+ }),
515
+ h('span', { style: 说明 }, '条(防封兜底)'),
516
+ ),
517
+ ),
474
518
  h(
475
519
  'div',
476
520
  { style: { ...卡片 } },
@@ -504,6 +548,24 @@ window.__ModuleLoader__.load({
504
548
  ),
505
549
  h(开关, { 开: 状态.autoTagStickers, 点: () => 改({ autoTagStickers: !状态.autoTagStickers }) }),
506
550
  ),
551
+ h(
552
+ 'div',
553
+ { style: { ...卡片 } },
554
+ h(
555
+ 'div',
556
+ null,
557
+ h('div', { style: 标题 }, '表情库上限'),
558
+ h('div', { style: 说明 }, `现在存了 ${状态.stickerCount ?? 0} 张。超过上限时淘汰最旧的记录(原文件不删)`),
559
+ ),
560
+ h('input', {
561
+ type: 'number',
562
+ min: 1,
563
+ max: 2000,
564
+ value: 值.stickerLimit,
565
+ onChange: (e) => 数字('stickerLimit', Number(e.target.value)),
566
+ style: 输入框,
567
+ }),
568
+ ),
507
569
  h(
508
570
  'div',
509
571
  { style: { display: 'flex', justifyContent: 'flex-end', gap: '10px', alignItems: 'center' } },
package/index.js CHANGED
@@ -61,10 +61,19 @@ const 默认配置 = {
61
61
  qzonePrompt: '',
62
62
  qzoneEveryMinMin: 120,
63
63
  qzoneEveryMinMax: 240,
64
- // 发完消息要不要考虑配表情包
65
- stickerOnReply: true,
66
- // 收到图要不要鉴定一下、收进表情库
67
- autoTagStickers: true,
64
+ // 一轮回复拆成几条发(像人一句一句说)
65
+ splitReply: true,
66
+ maxMessagesPerRound: 4,
67
+ splitGapMinMs: 1000,
68
+ splitGapMaxMs: 3000,
69
+ // 技术限速:不管几轮,每分钟最多几条(防封兜底)
70
+ perMinute: 6,
71
+ // 发完消息要不要考虑配表情包
72
+ stickerOnReply: true,
73
+ // 表情库最多存几张(超了淘汰最旧的记录,不动原文件)
74
+ stickerLimit: 200,
75
+ // 收到图要不要鉴定一下、收进表情库
76
+ autoTagStickers: true,
68
77
  // 上限开关:true = 不限条数(有些场景确实需要)
69
78
  limitsUnlimited: false,
70
79
  // 人设:留空则用中性说明;填了就把这段当作"你是谁"交给会话
@@ -156,6 +165,7 @@ export function apply(ctx, 配置) {
156
165
  const c = { ...默认配置, ...(配置 ?? {}), ...读取本地配置() };
157
166
  const 账本 = join(家目录(), 'sent.log');
158
167
  const 收件箱 = join(家目录(), 'inbox.jsonl');
168
+ const 轮次账 = join(家目录(), 'rounds.log');
159
169
 
160
170
  ctx.logger?.info?.(`qq-moony: 接到 ${c.baseUrl}(限额 ${c.perHour}/时、${c.perDay}/天)`);
161
171
 
@@ -690,9 +700,9 @@ export function apply(ctx, 配置) {
690
700
  const 记忆 = 读记忆片段();
691
701
  记调试(`主动模式:给它一次说话的机会(下一次排在 ${Math.round(下次.分钟)} 分钟后)`);
692
702
  投递到会话(
693
- `【主动】现在没有人在找你。你可以主动给 ${目标} 发一句话,也可以什么都不发。\n` +
703
+ `【主动】现在没有人在找你。你可以主动给 ${目标} 发一句话——**一句就够**,短到一个词、一个感叹号、一个颜文字都行,也可以配一张表情包。\n` +
694
704
  `想发就用 qq_send;不想发就直接结束这一轮,不用解释。\n` +
695
- `别每次都发,也别发天气预报那种东西。${记忆 ? `\n\n${记忆}` : ''}`,
705
+ `别每次都同一句,也别发天气预报那种东西。${记忆 ? `\n\n${记忆}` : ''}`,
696
706
  );
697
707
  }, 60000);
698
708
 
@@ -788,9 +798,14 @@ export function apply(ctx, 配置) {
788
798
  groupActiveWindowMin: Number(c.groupActiveWindowMin ?? 5),
789
799
  groupCooldownMin: Number(c.groupCooldownMin ?? 10),
790
800
  groupPerDay: Number(c.groupPerDay ?? 20),
791
- limitsUnlimited: c.limitsUnlimited === true,
792
- stickerOnReply: c.stickerOnReply !== false,
801
+ limitsUnlimited: c.limitsUnlimited === true,
802
+ stickerOnReply: c.stickerOnReply !== false,
793
803
  autoTagStickers: c.autoTagStickers !== false,
804
+ splitReply: c.splitReply !== false,
805
+ maxPerRound: Number(c.maxMessagesPerRound ?? 4),
806
+ perMinute: Number(c.perMinute ?? 6),
807
+ stickerLimit: Number(c.stickerLimit ?? 200),
808
+ stickerCount: 读表情库().length,
794
809
  perHour: Number(c.perHour ?? 20),
795
810
  perDay: Number(c.perDay ?? 60),
796
811
  sentToday: 记录.filter((t) => 现在 - t < 86400).length,
@@ -817,9 +832,13 @@ export function apply(ctx, 配置) {
817
832
  if (Number.isFinite(想要.everyMinMax)) patch.proactiveEveryMinMax = Math.max(5, Math.round(Number(想要.everyMinMax)));
818
833
  if (Number.isFinite(想要.perHour)) patch.perHour = Math.min(200, Math.max(1, Math.round(Number(想要.perHour))));
819
834
  if (Number.isFinite(想要.perDay)) patch.perDay = Math.min(1000, Math.max(1, Math.round(Number(想要.perDay))));
820
- if (typeof 想要.limitsUnlimited === 'boolean') patch.limitsUnlimited = 想要.limitsUnlimited;
821
- if (typeof 想要.stickerOnReply === 'boolean') patch.stickerOnReply = 想要.stickerOnReply;
835
+ if (typeof 想要.limitsUnlimited === 'boolean') patch.limitsUnlimited = 想要.limitsUnlimited;
836
+ if (typeof 想要.stickerOnReply === 'boolean') patch.stickerOnReply = 想要.stickerOnReply;
822
837
  if (typeof 想要.autoTagStickers === 'boolean') patch.autoTagStickers = 想要.autoTagStickers;
838
+ if (typeof 想要.splitReply === 'boolean') patch.splitReply = 想要.splitReply;
839
+ if (Number.isFinite(想要.maxPerRound)) patch.maxMessagesPerRound = Math.min(8, Math.max(1, Math.round(Number(想要.maxPerRound))));
840
+ if (Number.isFinite(想要.perMinute)) patch.perMinute = Math.min(60, Math.max(1, Math.round(Number(想要.perMinute))));
841
+ if (Number.isFinite(想要.stickerLimit)) patch.stickerLimit = Math.min(2000, Math.max(1, Math.round(Number(想要.stickerLimit))));
823
842
  if (typeof 想要.persona === 'string') patch.persona = 想要.persona.slice(0, 2000);
824
843
  if (typeof 想要.qzoneAuto === 'boolean') patch.qzoneAuto = 想要.qzoneAuto;
825
844
  if (typeof 想要.qzonePrompt === 'string') patch.qzonePrompt = 想要.qzonePrompt.slice(0, 2000);
@@ -900,13 +919,55 @@ export function apply(ctx, 配置) {
900
919
  if (c.limitsUnlimited === true) return '';
901
920
  const 现在 = Date.now() / 1000;
902
921
  const 记录 = 读账(账本);
922
+ const 一分钟 = 记录.filter((t) => 现在 - t < 60).length;
923
+ const 上限分钟 = Math.max(1, Number(c.perMinute ?? 6));
924
+ if (一分钟 >= 上限分钟) return `这一分钟已经发了 ${一分钟} 条,喘口气再说`;
903
925
  const 一小时 = 记录.filter((t) => 现在 - t < 3600).length;
904
926
  const 一天 = 记录.filter((t) => 现在 - t < 86400).length;
927
+ // 小时/天的额度按「轮」算(拆条模式下),分钟的硬上限永远按「条」算
928
+ const 轮记录 = 读账(轮次账);
929
+ const 轮小时 = 轮记录.filter((t) => 现在 - t < 3600).length;
930
+ const 轮一天 = 轮记录.filter((t) => 现在 - t < 86400).length;
931
+ if (c.splitReply !== false) {
932
+ if (轮小时 >= c.perHour) return `这一小时已经回了 ${轮小时} 轮,到顶了,等会儿再说`;
933
+ if (轮一天 >= c.perDay) return `今天已经回了 ${轮一天} 轮,到顶了,明天再聊`;
934
+ return '';
935
+ }
905
936
  if (一小时 >= c.perHour) return `这一小时已经发了 ${一小时} 条,到顶了,等会儿再说`;
906
937
  if (一天 >= c.perDay) return `今天已经发了 ${一天} 条,到顶了,明天再聊`;
907
938
  return '';
908
939
  }
909
940
 
941
+ /** 把一段话拆成该分开发的小句:标点断句、太短的并进上一句、最多 N 条。 */
942
+ function 拆成小段(整段) {
943
+ const 原文 = String(整段 ?? '').trim();
944
+ if (!原文) return [];
945
+ if (c.splitReply === false) return [原文];
946
+ const 最多 = Math.max(1, Math.min(8, Number(c.maxMessagesPerRound ?? 4)));
947
+ // 按句末标点或换行断
948
+ const 粗切 = 原文
949
+ .split(/(?<=[。!?!?…~\n])/u)
950
+ .map((x) => x.trim())
951
+ .filter(Boolean);
952
+ const 结果 = [];
953
+ for (const 句 of 粗切) {
954
+ // 太短的("哦。""!")并进上一句,除非上一句已经够长
955
+ if (结果.length > 0 && 句.length <= 2 && 结果[结果.length - 1].length < 30) {
956
+ 结果[结果.length - 1] += 句;
957
+ } else {
958
+ 结果.push(句);
959
+ }
960
+ }
961
+ // 超上限:把多出来的并到最后一条
962
+ if (结果.length > 最多) {
963
+ const 头 = 结果.slice(0, 最多 - 1);
964
+ const 尾 = 结果.slice(最多 - 1).join('');
965
+ 头.push(尾);
966
+ return 头;
967
+ }
968
+ return 结果;
969
+ }
970
+
910
971
  const 睡眠 = (ms) => new Promise((r) => setTimeout(r, ms));
911
972
 
912
973
  // ---------- qq_status ----------
@@ -977,22 +1038,42 @@ export function apply(ctx, 配置) {
977
1038
  if (!Number.isFinite(号) || 号 <= 0) return { ok: false, messageId: 0, reason: '目标不是有效号码' };
978
1039
 
979
1040
  const 消息 = [];
980
- if (正文) 消息.push({ type: 'text', data: { text: 正文 } });
1041
+ const 段们 = 正文 ? 拆成小段(正文) : [];
1042
+ if (段们.length === 0 && 正文) 段们.push(正文);
981
1043
  if (图) {
982
1044
  if (!existsSync(图)) return { ok: false, messageId: 0, reason: `找不到这张图:${图}` };
983
- 消息.push({ type: 'image', data: { file: 图.startsWith('file://') ? 图 : `file:///${图.replace(/\\/g, '/')}` } });
984
1045
  }
985
1046
 
986
- const 等待 = c.minDelayMs + Math.random() * Math.max(0, c.maxDelayMs - c.minDelayMs);
987
- await 睡眠(等待);
1047
+ const 重试等待 = () => c.minDelayMs + Math.random() * Math.max(0, c.maxDelayMs - c.minDelayMs);
1048
+ const 段间隔 = () =>
1049
+ Math.max(0, Number(c.splitGapMinMs ?? 1000)) +
1050
+ Math.random() * Math.max(0, Number(c.splitGapMaxMs ?? 3000) - Number(c.splitGapMinMs ?? 1000));
988
1051
 
1052
+ await 睡眠(重试等待());
1053
+ let 最后id = 0;
989
1054
  try {
990
- const r = await 调(是群 ? 'send_group_msg' : 'send_private_msg', {
991
- [是群 ? 'group_id' : 'user_id']: 号,
992
- message: 消息,
993
- });
994
- 记账(账本, 号, 正文 || `[图片]${图}`);
995
- return { ok: true, messageId: Number(r.data?.message_id ?? 0) };
1055
+ // 一句一句发,像人说话
1056
+ for (let i = 0; i < 段们.length; i += 1) {
1057
+ if (i > 0) await 睡眠(段间隔());
1058
+ const r = await 调(是群 ? 'send_group_msg' : 'send_private_msg', {
1059
+ [是群 ? 'group_id' : 'user_id']: 号,
1060
+ message: [{ type: 'text', data: { text: 段们[i] } }],
1061
+ });
1062
+ 最后id = Number(r.data?.message_id ?? 0);
1063
+ 记账(账本, 号, 段们[i]);
1064
+ }
1065
+ if (图) {
1066
+ if (段们.length > 0) await 睡眠(段间隔());
1067
+ const r = await 调(是群 ? 'send_group_msg' : 'send_private_msg', {
1068
+ [是群 ? 'group_id' : 'user_id']: 号,
1069
+ message: [{ type: 'image', data: { file: 图.startsWith('file://') ? 图 : `file:///${图.replace(/\\/g, '/')}` } }],
1070
+ });
1071
+ 最后id = Number(r.data?.message_id ?? 0);
1072
+ 记账(账本, 号, `[图片]${图}`);
1073
+ }
1074
+ // 一轮只记一份轮次额度
1075
+ 记账(轮次账, 号, 段们.join('') || '[图片]');
1076
+ return { ok: true, messageId: 最后id, parts: 段们.length + (图 ? 1 : 0) };
996
1077
  } catch (e) {
997
1078
  return { ok: false, messageId: 0, reason: e.message };
998
1079
  }
@@ -1118,6 +1199,11 @@ export function apply(ctx, 配置) {
1118
1199
  const 指纹 = 文件指纹(路径);
1119
1200
  const 库 = 读表情库();
1120
1201
  if (指纹 && 库.some((x) => x.hash === 指纹)) return { ok: true, added: false };
1202
+ const 上限 = Math.max(1, Number(c.stickerLimit ?? 200));
1203
+ while (库.length >= 上限) {
1204
+ const 淘汰 = 库.shift();
1205
+ 记调试(`表情库满(${上限}),淘汰最旧的:${淘汰?.path ?? '?'}`);
1206
+ }
1121
1207
  const 条目 = {
1122
1208
  path: 路径,
1123
1209
  hash: 指纹,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-qq-moony",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "把 QQ 小号接进 DeepSeek Harness:收发消息、发表情包、发 QQ 空间说说、参与群聊,能自己找话题,自带启动与限速防封。",
5
5
  "keywords": [
6
6
  "dsh",