dsh-world 0.6.4 → 0.6.5

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 (44) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/CONTRIBUTING.md +161 -161
  3. package/INSTALL.md +314 -311
  4. package/README.md +18 -13
  5. package/client/index.js +20 -1
  6. package/lib/compactionSwap.d.ts +29 -0
  7. package/lib/compactionSwap.js +56 -0
  8. package/lib/console/routes.d.ts +2 -0
  9. package/lib/console/routes.js +6 -0
  10. package/lib/console/snapshot.d.ts +1 -0
  11. package/lib/console/snapshot.js +1 -0
  12. package/lib/ecosystem/qq-native/home.js +2 -2
  13. package/lib/ecosystem/shared/context.d.ts +2 -1
  14. package/lib/ecosystem/shared/context.js +8 -1
  15. package/lib/ecosystem/wechat/home.js +4 -4
  16. package/lib/ecosystem/wechat/index.js +13 -4
  17. package/lib/ecosystem/wechat/messages.js +1 -1
  18. package/lib/ecosystem/wechat/reading.d.ts +9 -0
  19. package/lib/ecosystem/wechat/reading.js +61 -12
  20. package/lib/ecosystem/wechat/sending.d.ts +1 -0
  21. package/lib/ecosystem/wechat/sending.js +3 -0
  22. package/lib/ecosystem/wechat/stickerLibrary.d.ts +50 -0
  23. package/lib/ecosystem/wechat/stickerLibrary.js +167 -0
  24. package/lib/ecosystem/wechat/stickerTools.d.ts +18 -0
  25. package/lib/ecosystem/wechat/stickerTools.js +69 -0
  26. package/lib/index.d.ts +3 -1
  27. package/lib/index.js +9 -2
  28. package/lib/life/activity.js +5 -0
  29. package/lib/life/runtime.d.ts +2 -0
  30. package/lib/life/runtime.js +4 -2
  31. package/lib/runtime.js +36 -37
  32. package/lib/types.d.ts +2 -0
  33. package/lib/worldSwitch.d.ts +7 -1
  34. package/lib/worldSwitch.js +12 -1
  35. package/package.json +115 -114
  36. package/scripts/import-stickers.mjs +17 -0
  37. package/src/ecosystem/wechat/windows/README.md +22 -5
  38. package/src/ecosystem/wechat/windows/adapter.py +24 -5
  39. package/src/ecosystem/wechat/windows/groups.py +2 -2
  40. package/src/ecosystem/wechat/windows/native.py +4 -3
  41. package/src/ecosystem/wechat/windows/outgoing.py +14 -7
  42. package/src/ecosystem/wechat/windows/reading.py +58 -1
  43. package/src/ecosystem/wechat/windows/snapshot.py +8 -0
  44. package/src/ecosystem/wechat/windows/stickers.py +69 -60
@@ -5,7 +5,7 @@ import { validArchiveScope } from "./archiveSelection.js";
5
5
  /** How many of the newest turns the memory block carries when nothing else is set. */
6
6
  export const DEFAULT_MEMORY_TURNS = 200;
7
7
  /** WeChat group messages read before a mention, and seconds a private image waits for its companions. */
8
- export const DEFAULT_WECHAT_CONTEXT = 8, MAX_WECHAT_CONTEXT = 20;
8
+ export const DEFAULT_WECHAT_CONTEXT = 5, MAX_WECHAT_CONTEXT = 20;
9
9
  export const DEFAULT_WECHAT_BUFFER_SECONDS = 3, MAX_WECHAT_BUFFER_SECONDS = 30;
10
10
  /**
11
11
  * Her world's own settings, changed from the world console and kept in `<world>/world.json` so they outlast a
@@ -26,6 +26,8 @@ export function createWorldSwitch(worldDir, configuredScope = 'workspace') {
26
26
  ...(validWechatBuffer(stored?.wechatBufferSeconds) ? { wechatBufferSeconds: stored.wechatBufferSeconds } : {}),
27
27
  ...(stored?.wechatEnabled === false ? { wechatEnabled: false } : {}),
28
28
  ...(typeof stored?.qqEnabled === 'boolean' ? { qqEnabled: stored.qqEnabled } : {}),
29
+ ...(stored?.compactAfterReview === false ? { compactAfterReview: false } : {}),
30
+ ...(stored?.compactionSwapped === true ? { compactionSwapped: true } : {}),
29
31
  };
30
32
  const activeScope = settings.archiveScope ?? configuredScope;
31
33
  function save(next) {
@@ -47,6 +49,15 @@ export function createWorldSwitch(worldDir, configuredScope = 'workspace') {
47
49
  throw new Error('工作区权限必须为非空字符串');
48
50
  save({ ...settings, workspacePermission: permission });
49
51
  },
52
+ /** After each evening look back her home is applied and compacted, so the 我是谁 she has just rewritten takes effect; on unless the partner turns it off. */
53
+ compactAfterReview: () => settings.compactAfterReview !== false,
54
+ setCompactAfterReview(on) {
55
+ const { compactAfterReview: _old, ...rest } = settings;
56
+ save(on ? rest : { ...rest, compactAfterReview: false });
57
+ },
58
+ /** Whether the profile has been switched to the tool-free compaction (by this plugin or by hand); asked once, so putting the stock one back sticks. */
59
+ compactionSwapped: () => settings.compactionSwapped === true,
60
+ markCompactionSwapped: () => save({ ...settings, compactionSwapped: true }),
50
61
  running: () => settings.running,
51
62
  set: (running) => save({ ...settings, running }),
52
63
  /** CNY a day for her home; undefined is no limit. */
package/package.json CHANGED
@@ -1,114 +1,115 @@
1
- {
2
- "name": "dsh-world",
3
- "version": "0.6.4",
4
- "description": "Cross-session memory, persona and life routines for DSH, with a world console, tool-free compaction, native QQ and Windows WeChat messaging, group history and quoted replies",
5
- "license": "MIT",
6
- "publishConfig": {
7
- "registry": "https://registry.npmjs.org/",
8
- "access": "public"
9
- },
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/chenkezhen480/dsh-world.git"
13
- },
14
- "type": "module",
15
- "main": "lib/index.js",
16
- "types": "lib/index.d.ts",
17
- "exports": {
18
- ".": {
19
- "types": "./lib/index.d.ts",
20
- "default": "./lib/index.js"
21
- },
22
- "./compaction": {
23
- "types": "./lib/compaction.d.ts",
24
- "default": "./lib/compaction.js"
25
- },
26
- "./client": "./client/index.js",
27
- "./package.json": "./package.json"
28
- },
29
- "files": [
30
- "lib",
31
- "client",
32
- "src/ecosystem/wechat/windows",
33
- "src/ecosystem/README.md",
34
- "templates",
35
- "cordis.patch.yml",
36
- "README.md",
37
- "INSTALL.md",
38
- "CONTRIBUTING.md",
39
- "CHANGELOG.md",
40
- "assets",
41
- "scripts/enable-tool-free-compaction.ps1",
42
- "scripts/update-world.mjs"
43
- ],
44
- "engines": {
45
- "node": ">=22.16.0",
46
- "dsh": ">=0.2.1-rc.2 <0.3.0-0"
47
- },
48
- "scripts": {
49
- "build": "node -e \"require('fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
50
- "typecheck": "tsc --noEmit",
51
- "test": "node --experimental-strip-types --disable-warning=ExperimentalWarning --test",
52
- "test:package": "node scripts/check-package.mjs",
53
- "prepack": "npm run typecheck && npm run build"
54
- },
55
- "dsh": {
56
- "bundle": {
57
- "patch": "./cordis.patch.yml"
58
- },
59
- "client": {
60
- "inject": [
61
- "@deepseek-ai/dsh-client-ui-settings"
62
- ],
63
- "platform": "web"
64
- },
65
- "compatibility": {
66
- "dshReleases": {
67
- "0.2.1-rc.2": "compatible",
68
- "0.2.1-rc.3": "compatible"
69
- }
70
- }
71
- },
72
- "dependencies": {
73
- "ws": "^8.18.0"
74
- },
75
- "peerDependencies": {
76
- "@snowluma/sdk": "^2.2.1",
77
- "@deepseek-ai/dsh-plugin-manager": ">=0.2.1-rc.3 <0.3.0-0",
78
- "@deepseek-ai/dsh-atomic-write": ">=0.2.1-rc.3 <0.3.0-0",
79
- "@deepseek-ai/cordis": "~4.0.5",
80
- "@deepseek-ai/dsh-agent": ">=0.2.1-rc.2 <0.3.0-0",
81
- "@deepseek-ai/dsh-attachment": ">=0.2.1-rc.2 <0.3.0-0",
82
- "@deepseek-ai/dsh-compaction": ">=0.2.1-rc.2 <0.3.0-0",
83
- "@deepseek-ai/dsh-home-paths": ">=0.2.1-rc.2 <0.3.0-0",
84
- "@deepseek-ai/dsh-llm": ">=0.2.1-rc.2 <0.3.0-0",
85
- "@deepseek-ai/dsh-session": ">=0.2.1-rc.2 <0.3.0-0",
86
- "@deepseek-ai/dsh-session-persistence": ">=0.2.1-rc.2 <0.3.0-0",
87
- "@deepseek-ai/dsh-tools": ">=0.2.1-rc.2 <0.3.0-0",
88
- "@deepseek-ai/schemastery": "~3.18.5",
89
- "@deepseek-ai/dsh-commands": ">=0.2.1-rc.2 <0.3.0-0"
90
- },
91
- "peerDependenciesMeta": {
92
- "@snowluma/sdk": {
93
- "optional": true
94
- },
95
- "@deepseek-ai/dsh-plugin-manager": {
96
- "optional": true
97
- },
98
- "@deepseek-ai/dsh-atomic-write": {
99
- "optional": true
100
- }
101
- },
102
- "devDependencies": {
103
- "@snowluma/sdk": "2.2.1",
104
- "@deepseek-ai/dsh-agent-loop": "0.2.1-rc.2",
105
- "@deepseek-ai/dsh-compaction-basic": "0.2.1-rc.2",
106
- "@deepseek-ai/dsh-session-persistence-jsonl": "0.2.1-rc.2",
107
- "@deepseek-ai/dsh-session-projection": "0.2.1-rc.2",
108
- "@deepseek-ai/dsh-system-prompt": "0.2.1-rc.2",
109
- "@deepseek-ai/dsh-token-meter": "0.2.1-rc.2",
110
- "@deepseek-ai/dsh-tools": "0.2.1-rc.2",
111
- "@types/node": "^22.16.0",
112
- "typescript": "^5.9.3"
113
- }
114
- }
1
+ {
2
+ "name": "dsh-world",
3
+ "version": "0.6.5",
4
+ "description": "Cross-session memory, persona and life routines for DSH, with a world console, tool-free compaction, native QQ and Windows WeChat messaging, group history and quoted replies",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "registry": "https://registry.npmjs.org/",
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/chenkezhen480/dsh-world.git"
13
+ },
14
+ "type": "module",
15
+ "main": "lib/index.js",
16
+ "types": "lib/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./lib/index.d.ts",
20
+ "default": "./lib/index.js"
21
+ },
22
+ "./compaction": {
23
+ "types": "./lib/compaction.d.ts",
24
+ "default": "./lib/compaction.js"
25
+ },
26
+ "./client": "./client/index.js",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "lib",
31
+ "client",
32
+ "src/ecosystem/wechat/windows",
33
+ "src/ecosystem/README.md",
34
+ "templates",
35
+ "cordis.patch.yml",
36
+ "README.md",
37
+ "INSTALL.md",
38
+ "CONTRIBUTING.md",
39
+ "CHANGELOG.md",
40
+ "assets",
41
+ "scripts/enable-tool-free-compaction.ps1",
42
+ "scripts/update-world.mjs",
43
+ "scripts/import-stickers.mjs"
44
+ ],
45
+ "engines": {
46
+ "node": ">=22.16.0",
47
+ "dsh": ">=0.2.1-rc.2 <0.3.0-0"
48
+ },
49
+ "scripts": {
50
+ "build": "node -e \"require('fs').rmSync('lib',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
51
+ "typecheck": "tsc --noEmit",
52
+ "test": "node --experimental-strip-types --disable-warning=ExperimentalWarning --test",
53
+ "test:package": "node scripts/check-package.mjs",
54
+ "prepack": "npm run typecheck && npm run build"
55
+ },
56
+ "dsh": {
57
+ "bundle": {
58
+ "patch": "./cordis.patch.yml"
59
+ },
60
+ "client": {
61
+ "inject": [
62
+ "@deepseek-ai/dsh-client-ui-settings"
63
+ ],
64
+ "platform": "web"
65
+ },
66
+ "compatibility": {
67
+ "dshReleases": {
68
+ "0.2.1-rc.2": "compatible",
69
+ "0.2.1-rc.3": "compatible"
70
+ }
71
+ }
72
+ },
73
+ "dependencies": {
74
+ "ws": "^8.18.0"
75
+ },
76
+ "peerDependencies": {
77
+ "@snowluma/sdk": "^2.2.1",
78
+ "@deepseek-ai/dsh-plugin-manager": ">=0.2.1-rc.3 <0.3.0-0",
79
+ "@deepseek-ai/dsh-atomic-write": ">=0.2.1-rc.3 <0.3.0-0",
80
+ "@deepseek-ai/cordis": "~4.0.5",
81
+ "@deepseek-ai/dsh-agent": ">=0.2.1-rc.2 <0.3.0-0",
82
+ "@deepseek-ai/dsh-attachment": ">=0.2.1-rc.2 <0.3.0-0",
83
+ "@deepseek-ai/dsh-compaction": ">=0.2.1-rc.2 <0.3.0-0",
84
+ "@deepseek-ai/dsh-home-paths": ">=0.2.1-rc.2 <0.3.0-0",
85
+ "@deepseek-ai/dsh-llm": ">=0.2.1-rc.2 <0.3.0-0",
86
+ "@deepseek-ai/dsh-session": ">=0.2.1-rc.2 <0.3.0-0",
87
+ "@deepseek-ai/dsh-session-persistence": ">=0.2.1-rc.2 <0.3.0-0",
88
+ "@deepseek-ai/dsh-tools": ">=0.2.1-rc.2 <0.3.0-0",
89
+ "@deepseek-ai/schemastery": "~3.18.5",
90
+ "@deepseek-ai/dsh-commands": ">=0.2.1-rc.2 <0.3.0-0"
91
+ },
92
+ "peerDependenciesMeta": {
93
+ "@snowluma/sdk": {
94
+ "optional": true
95
+ },
96
+ "@deepseek-ai/dsh-plugin-manager": {
97
+ "optional": true
98
+ },
99
+ "@deepseek-ai/dsh-atomic-write": {
100
+ "optional": true
101
+ }
102
+ },
103
+ "devDependencies": {
104
+ "@snowluma/sdk": "2.2.1",
105
+ "@deepseek-ai/dsh-agent-loop": "0.2.1-rc.2",
106
+ "@deepseek-ai/dsh-compaction-basic": "0.2.1-rc.2",
107
+ "@deepseek-ai/dsh-session-persistence-jsonl": "0.2.1-rc.2",
108
+ "@deepseek-ai/dsh-session-projection": "0.2.1-rc.2",
109
+ "@deepseek-ai/dsh-system-prompt": "0.2.1-rc.2",
110
+ "@deepseek-ai/dsh-token-meter": "0.2.1-rc.2",
111
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.2",
112
+ "@types/node": "^22.16.0",
113
+ "typescript": "^5.9.3"
114
+ }
115
+ }
@@ -0,0 +1,17 @@
1
+ // One-time import of the older sticker plugin's library (catalog.json + assets/) into the world's own sticker library.
2
+ // Usage: node scripts/import-stickers.mjs <older library folder, e.g. ~/.dsh/memes> <world folder, e.g. ~/.dsh/worlds/<id>>
3
+ // Ids, names and tags carry over; packs and long meanings do not. Running it again adds nothing. The older folder is only read.
4
+ import { join, resolve } from 'node:path';
5
+ import { pathToFileURL, fileURLToPath } from 'node:url';
6
+
7
+ const [legacy, world] = process.argv.slice(2);
8
+ if (!legacy || !world) {
9
+ console.error('用法:node scripts/import-stickers.mjs <旧表情包目录> <世界目录>');
10
+ process.exit(2);
11
+ }
12
+ const modulePath = join(fileURLToPath(new URL('..', import.meta.url)), 'lib', 'ecosystem', 'wechat', 'stickerLibrary.js');
13
+ const { createStickerLibrary, importLegacyStickers } = await import(pathToFileURL(modulePath).href);
14
+ const library = createStickerLibrary(join(resolve(world), 'stickers'));
15
+ const report = await importLegacyStickers(resolve(legacy), library);
16
+ console.log(`导入 ${report.imported} 张,已在库里的 ${report.duplicates} 张,跳过 ${report.skipped.length} 张;表情库现有 ${(await library.list()).length} 张。`);
17
+ for (const line of report.skipped) console.log(` 跳过 ${line}`);
@@ -13,11 +13,12 @@ DSH 端代码在 `src/ecosystem/wechat/`,此目录只负责 Windows 微信收
13
13
  - 后台原生发送文字、图片及 GIF 表情,不操作窗口、鼠标、键盘或剪贴板。按 `engine/profiles.json` 中的文件大小与 SHA256 自动选择 Windows x64 微信 4.1.15.13 / 4.1.15.56 的指定构建;未知或匹配不唯一的构建会在加载原生桥前报错,不能只凭版本号判断兼容。
14
14
  - 发言人使用真实 wxid,消息保持 `role: user`;昵称只作显示。
15
15
  - 每个群按平台、登录账号、聊天类型和群 ID 绑定独立会话,列在原房间工作区下,重启继续复用;群名变化不产生新会话。QQ 的相同参数可使用同一映射结构,但本模块仍只提供微信传输。
16
- - 群消息只在微信来源元数据的 `atuserlist` 包含她的真实 wxid 时触发,正文里手打 `@昵称` 不触发。回复携带当前消息之前最多 8 条、16KB 的同群上下文,保留各自的发言人 ID;每轮最多读取其中最近两张图片。
16
+ - 群消息只在微信来源元数据的 `atuserlist` 包含她的真实 wxid 时触发,正文里手打 `@昵称` 不触发。回复携带当前消息之前默认 5 条(控制台可调,1–20)、最多 16KB 的同群上下文,保留各自的发言人 ID;每轮最多读取其中最近两张图片。
17
+ - 她发出的文字里写 `@昵称` 只是普通文字,不会真的 @ 到人,被提到的人也不会收到“有人@我”的提醒。原生发送只有文字、图片、表情三种调用,没有 @ 列表;靠操作窗口输入 @ 的做法会抢占焦点,与“不操作窗口、鼠标、键盘”冲突,所以不做。需要点名时让她在文字里写清对方的称呼。
17
18
  - 支持私聊与群聊中的引用回复(应用消息 49 / 子类型 57):当前留言与引用的原作者、原消息 ID、类型和内容分别传递。记忆中将留言和明确标为“引用”的文字保留在同一条用户记录里,只引用、不写留言时也保留这次发言,不额外增加一轮。引用图片和表情包只留文字类型与当时的读取状态,附件索引不写入共享记忆;是否另存相册由她自行决定。群聊引用仍沿用真实 @ 触发规则;历史读取使用同一解析。嵌套引用只读取所引用消息的标题,不展开整条引用链。
18
19
  - 引用图片和表情包时,仅在绑定账号、同一聊天内按原消息服务器 ID 定位,并核对原作者和媒体类型,再复用图片缓存读取或表情包下载校验;缺失、歧义或无法读取时明确标注未读取,保留当前留言。GIF 沿用单帧 PNG 处理,并注明“仅查看首帧”。
19
20
  - 引用部分的解析或媒体读取错误会在适配器日志与本地 `state.sqlite` 的 `messageFaults` 中记录。完全损坏的应用消息在进入模型前标为 `rejected` 并继续后续消息,不再反复卡在同一条;宿主请求失败、发送结果不明仍保留原有待核对保护,不自动重跑或重发。
20
- - 无需等别人 @,世界会话也可主动调用 `wechat_history` 读取已配置群聊:先用 `wechat_chats` 找到准确 `chatId`,再调用 `wechat_history({ chatId, limit: 10 })`。返回按时间排列的消息、真实发言人、平台消息 ID 和 `nextCursor`;继续看更早记录时把游标原样传回。每页最多 20 条、16KB,使用消息位置游标,翻页期间新消息不会挤乱旧页。图片、视频等媒体仅返回明确的未读取标记,不读取媒体正文;系统事件不作为群成员发言。读完以返回的同一个 `chatId` 调用 `wechat_send`,再用 `wechat_delivery` 核对结果。读取不自动发言、不改接收游标,也不会把整段群聊写入共享记忆。
21
+ - 无需等别人 @,世界会话也可主动调用 `wechat_history` 读取已配置群聊:先用 `wechat_chats` 找到准确 `chatId`,再调用 `wechat_history({ chatId, limit: 10 })`。返回按时间排列的消息、真实发言人、平台消息 ID 和 `nextCursor`;继续看更早记录时把游标原样传回。每页最多 20 条、16KB,使用消息位置游标,翻页期间新消息不会挤乱旧页。图片、视频等媒体先只返回明确的未读取标记,不读取媒体正文;想看某张图片,用该条的 `id` 调用 `wechat_image({ chatId, messageId })`:适配器只在已配置群聊、按本机缓存读取这一张(同一套缓存读取和来源核对),作为下一条消息附给她;图片尚未下载到这台电脑、已不在缓存或 ID 不是图片时明确报错,不推进收消息游标,也不清未读。系统事件不作为群成员发言。读完以返回的同一个 `chatId` 调用 `wechat_send`,再用 `wechat_delivery` 核对结果。读取不自动发言、不改接收游标,也不会把整段群聊写入共享记忆。
21
22
  - 主动读取复用现有适配器轮询、账号与群白名单、数据库快照和消息身份解析,宿主连接文件协议为 4,并以 `references: true` 声明引用支持。升级时需同时更新插件与 Windows 适配器并重启适配器;新版适配器启动前检查引用能力,旧宿主需先更新,避免只发送留言而静默丢掉引用。旧适配器仍能收发,但无法处理主动读取,读取工具会在 30 秒后明确报超时。关闭微信模块或取消当前回合会结束尚未完成的读取。
22
23
  - 群图片使用所选账号、群和消息分片的本机缓存,注明原图、缓存版本或缩略图;不操作界面下载。图片尚未缓存或不可解码时,明确标记未读取。图片通过宿主附件服务保存,仅在群会话总日志的上下文事件中保留引用,不进入共享记忆归档。
23
24
  - 账号和聊天必须显式绑定;进程只根据配置账号的数据库持有情况识别,不选择其他账号。微信退出时暂停收发,原账号重新登录后自动重新绑定进程、重建读取和发送连接,沿用原消息游标,不需要重启 DSH。
@@ -25,7 +26,7 @@ DSH 端代码在 `src/ecosystem/wechat/`,此目录只负责 Windows 微信收
25
26
  - 自动恢复只处理微信离线;账号或候选进程不唯一、其他运行错误仍明确停止。消息处理过程中中断且结果未确认时,保留待核对状态,不重跑模型、不重发消息,按下文 `Acknowledge` 流程处理。
26
27
  - 微信请求依次进入原家或对应群会话;她自主调用工具决定是否回复、回复哪个已配置聊天,不广播到桌宠等其他出口。
27
28
  - `Send` 可向已配置聊天主动发文字。首版需要先停止 `Start` 再单独使用,不支持多进程同时操作微信。
28
- - 世界会话长期提供 `wechat_chats`(分页列目标)、`wechat_history`(主动分页读群消息)、`wechat_send`(指定 chatId 和 1–16 条 messages)和 `wechat_delivery`(按发送 ID 查询结果)。她在桌面聊天、闹钟唤醒或每日回顾时,也能主动查看群聊并联系已配置的私聊和群。主动联系与回复共用同一队列和发送函数,不需要先停监听。`Send` 命令只供停监听后的人工操作。
29
+ - 世界会话长期提供 `wechat_chats`(分页列目标)、`wechat_history`(主动分页读群消息)、`wechat_image`(按消息 ID 看群里的一张图)、`wechat_send`(指定 chatId 和 1–16 条 messages)和 `wechat_delivery`(按发送 ID 查询结果)。她在桌面聊天、闹钟唤醒或每日回顾时,也能主动查看群聊并联系已配置的私聊和群。主动联系与回复共用同一队列和发送函数,不需要先停监听。`Send` 命令只供停监听后的人工操作。
29
30
  - 仅调用 `wechat_send` 才安排投递,普通最终文字不自动转发。每批消息按数组顺序发送,可穿插短句、图片和表情;不同批次按入队顺序处理。工具返回 queued 只表示排队,尚未确认发送,不能当作已发出或重复调用。
30
31
  - 发送正文作为独立的微信出站上下文写入发起会话总日志,按原记忆范围记录真实正文和目标;普通工具参数、回执查询和群图片上下文仍不进入共享记忆。她留在 DSH 的最终文字与微信正文可以不同,归档分别保留。
31
32
  - 生成时先记为待发送,适配器逐条核对目标聊天中的本人消息、内容及服务器消息编号,再更新同一条记忆的已确认条数。中断则记录已确认部分与结果不明项;这不是对方已读回执。
@@ -58,14 +59,13 @@ DSH 端代码在 `src/ecosystem/wechat/`,此目录只负责 Windows 微信收
58
59
  "moduleFile": "C:/Users/you/.dsh/worlds/your-world/wechat.json",
59
60
  "pageSize": 50,
60
61
  "pollSeconds": 2,
61
- "stickerDir": "C:/Users/you/.dsh/memes",
62
62
  "mediaRoots": ["C:/Users/you/.dsh/worlds/your-world/room"],
63
63
  "chats": [{ "id": "family@chatroom", "name": "家庭群名称", "trigger": "mention" }],
64
64
  "people": { "wxid_family_member": "妈妈" }
65
65
  }
66
66
  ```
67
67
 
68
- `account` 是 `dbDir` 下她的账号目录名,`selfId` 是她的真实微信内部 ID,不是昵称。启动及重连只绑定唯一持有此账号数据库的微信进程,每个连接固定 PID 和启动时间,读取和发送前重复核对。旧配置中的 `processId` 可删除,保留时也不再固定选择旧进程。发送直接使用 `chats[].id`,`name` 仅供显示。`stickerDir` 复用已安装的表情包目录,`mediaRoots` 限制本地媒体发送范围。
68
+ `account` 是 `dbDir` 下她的账号目录名,`selfId` 是她的真实微信内部 ID,不是昵称。启动及重连只绑定唯一持有此账号数据库的微信进程,每个连接固定 PID 和启动时间,读取和发送前重复核对。旧配置中的 `processId` 可删除,保留时也不再固定选择旧进程。发送直接使用 `chats[].id`,`name` 仅供显示。表情包不再需要配置目录:她的表情库固定放在 `moduleFile` 同目录的 `stickers/` 下(见下文“表情库”),旧配置里的 `stickerDir` 会被忽略。`mediaRoots` 限制本地媒体发送范围。
69
69
 
70
70
  统一身份参数如下。微信入站由本机数据库解析;QQ 一列按 [OneBot 11 消息事件](https://github.com/botuniverse/onebot-11/blob/master/event/message.md) 映射,未启用 QQ 传输。
71
71
 
@@ -119,6 +119,23 @@ wechatAdapter:
119
119
 
120
120
  升级前保留配置文件所在目录下的账号子目录(含 `state.sqlite`)、`engine` 和 Python 环境。不要依赖包管理器保留 `node_modules/dsh-world/.host/wechat`;安装在包目录中的运行数据应先完整备份或移到包目录外,并同步调整 `wechatAdapter.python`、`wechatAdapter.config` 及手动命令的 `-ConfigFile`。仅在包内修改 `adapter.py` 的热补丁会被升级覆盖。
121
121
 
122
+ ## 表情库
123
+
124
+ 她自己的表情库在 `moduleFile` 同目录的 `stickers/` 里:`index.json` 加 `files/` 里的图片,每张只有名称和 1–8 个标签(标签就是摘要),没有表情包分组。最多 500 张,单张 10MB 以内,按图片内容去重,只收 GIF、PNG、WebP、JPEG。
125
+
126
+ - `sticker_search`:按关键词查名称和标签,返回最多 8 张(最多 20)的 `id|名称|标签`,不带关键词看最近收的。不会一次倾倒整个库,也不会把图片发给模型。
127
+ - `sticker_save`:把聊天里别人发来的表情包收进库。适配器按消息 ID 在已配置的私聊或群聊里取出原始表情(腾讯 CDN,md5 与消息一致才接受,保留动图),暂存在 `stickers/incoming/`,宿主核对后收下并删掉暂存。群里的表情在 `wechat_history` 中显示为“表情包,未读取”,用那条的 `id`。
128
+ - `sticker_edit`、`sticker_remove`:改名称标签、删除。
129
+ - 发送仍是 `wechat_send` 的 `{ "type": "sticker", "stickerId": "st_…" }`;表情不在库里时排队就会被拒绝,不会等到发送时才失败。发送结果只返回文字,不把图片回传给模型。
130
+
131
+ 从旧的 DeepSeek-meme 插件迁移(只需一次,旧目录只读不改,重复运行不会重复导入;编号、名称和标签保留,分组和长段含义不导入):
132
+
133
+ ```powershell
134
+ node <dsh-world 包目录>scriptsimport-stickers.mjs C:Usersyou.dshmemes C:Usersyou.dshworldsyour-world
135
+ ```
136
+
137
+ 导入后可以在 DSH 的 profile 里去掉 `dsh-deepseek-meme`。QQ 仍用它自己的收藏表情,与这里无关。
138
+
122
139
  ## 验证边界
123
140
 
124
141
  单元测试不连接微信。读取仍使用所选账号的本地数据库;发送桥加载到已绑定的微信进程,在微信自己的任务线程提交操作,不使用界面发送。停止适配器会取消尚未提交的原生任务;已交给微信的异步发送不能撤销,结果不确定时仍需人工核对。桥在微信退出时释放,未安装接收 Hook。
@@ -13,8 +13,8 @@ from urllib.parse import urlparse
13
13
  from urllib.error import HTTPError, URLError
14
14
  from urllib.request import Request, urlopen
15
15
  from outgoing import deliverMessages, prepareMessages
16
- from snapshot import SnapshotBusy
17
- from groups import MAX_CONTEXT_MESSAGES, mentionedIds, recentContext
16
+ from snapshot import NotSent, SnapshotBusy
17
+ from groups import DEFAULT_CONTEXT_MESSAGES, mentionedIds, recentContext
18
18
  from incoming import ImageUnavailable, admitReference
19
19
  from binding import WechatOffline
20
20
  from messages import messageKey, messageOrder, normalizeMessage, messageNormalizer, messageEntry, displayMessage
@@ -30,6 +30,8 @@ CONTROL_TIMEOUT_SECONDS = 5
30
30
  REPLY_TIMEOUT_SECONDS = 190
31
31
  # Exit code for "a person must check WeChat and run acknowledge"; the plugin stops restarting the adapter on it.
32
32
  EXIT_NEEDS_HUMAN = 2
33
+ # A reply whose send never started (no stable snapshot of the chat) is handed out again this many times in all.
34
+ SEND_ATTEMPTS = 3
33
35
 
34
36
 
35
37
  class NeedsHuman(RuntimeError):
@@ -83,8 +85,6 @@ def validateConfig(config, requireChats=True):
83
85
  raise ValueError('pageSize 应在 1–100 之间')
84
86
  if not 0.5 <= config.get('pollSeconds', 2) <= 60:
85
87
  raise ValueError('pollSeconds 应在 0.5–60 之间')
86
- if 'stickerDir' in config and (not isinstance(config['stickerDir'], str) or not Path(config['stickerDir']).is_absolute()):
87
- raise ValueError('stickerDir 需要绝对目录')
88
88
  if 'mediaRoots' in config and (not isinstance(config['mediaRoots'], list) or any(not isinstance(root, str) or not Path(root).is_absolute() for root in config['mediaRoots'])):
89
89
  raise ValueError('mediaRoots 需要绝对目录数组')
90
90
  return config
@@ -166,7 +166,7 @@ def incomingMessage(database, chat, row, config):
166
166
  if previous['type'] == '图片':
167
167
  entry['imageRow'] = {key: previous[key] for key in ('local_id','shard','sort_seq','create_time','type','sender_username')}
168
168
  return entry
169
- message['context'] = recentContext(database, chat['id'], row, contextMessage, config.get('pageSize', 50), config.get('contextMessages', MAX_CONTEXT_MESSAGES))
169
+ message['context'] = recentContext(database, chat['id'], row, contextMessage, config.get('pageSize', 50), config.get('contextMessages', DEFAULT_CONTEXT_MESSAGES))
170
170
  imageCount = 0
171
171
  for entry in reversed(message['context']):
172
172
  picture = entry.pop('imageRow', None)
@@ -195,6 +195,7 @@ class MessageStore:
195
195
  PRIMARY KEY(chatId,messageId));
196
196
  ''')
197
197
  self.presence = GroupPresence(self.db)
198
+ self.notSent = {} # replyId -> attempts that gave up before sending; in memory, a restart starts over
198
199
 
199
200
  def cursor(self, chatId):
200
201
  row = self.db.execute('SELECT seq FROM cursors WHERE chatId=?', (chatId,)).fetchone()
@@ -271,6 +272,12 @@ class MessageStore:
271
272
  if result.rowcount != 1:
272
273
  raise ValueError('回执进度无效')
273
274
 
275
+ def abandonReply(self, replyId, chatId):
276
+ """Undo `beginReply` for a reply that never started sending; the home still holds the job as claimed."""
277
+ with self.db:
278
+ self.db.execute("DELETE FROM receipts WHERE replyId=? AND confirmed=0 AND status='uncertain'", (replyId,))
279
+ self.db.execute("DELETE FROM messages WHERE chatId=? AND messageId=? AND status='pending'", (chatId, 'outgoing:' + replyId))
280
+
274
281
  def failReply(self, replyId):
275
282
  with self.db:
276
283
  self.db.execute("UPDATE receipts SET status='failed', posted=0 WHERE replyId=? AND confirmed=0", (replyId,))
@@ -424,6 +431,18 @@ def flushOutgoing(config, database, store, send, *, poll=pollOutgoing, report=re
424
431
  try:
425
432
  deliverMessages(prepared, lambda item: send(chat, item), lambda count: store.confirmReply(replyId, count))
426
433
  store.finish(chat['id'], key, seq)
434
+ except NotSent as error:
435
+ # Nothing was sent: forget this attempt and let the next poll hand the same job out again (#23),
436
+ # but only so many times; after that it stays as an unconfirmed send for a person to check.
437
+ attempts = store.notSent[replyId] = store.notSent.get(replyId, 0) + 1
438
+ if attempts >= SEND_ATTEMPTS:
439
+ store.notSent.pop(replyId, None)
440
+ raise RuntimeError(f'连续 {attempts} 次发送前都未能获取稳定的目标聊天快照,消息没有发出;请人工核对后处理') from error
441
+ store.abandonReply(replyId, chat['id'])
442
+ print(f'微信发送尚未开始(第 {attempts}/{SEND_ATTEMPTS} 次),稍后重试同一任务:{error}', file=sys.stderr, flush=True)
443
+ raise
444
+ else:
445
+ store.notSent.pop(replyId, None)
427
446
  finally:
428
447
  flushReceipts(config['moduleFile'], store, report)
429
448
 
@@ -3,7 +3,7 @@ import json
3
3
  from xml.etree import ElementTree
4
4
  from messages import MAX_CONTEXT_BYTES, messageOrder, recentEntries
5
5
 
6
- MAX_CONTEXT_MESSAGES = 8
6
+ DEFAULT_CONTEXT_MESSAGES = 5
7
7
 
8
8
 
9
9
  def mentionedIds(source):
@@ -20,7 +20,7 @@ def mentionedIds(source):
20
20
  return [value.strip() for value in (root.findtext('atuserlist') or '').split(',') if value.strip()]
21
21
 
22
22
 
23
- def recentContext(database, chatId, current, normalize, pageSize=50, limit=MAX_CONTEXT_MESSAGES):
23
+ def recentContext(database, chatId, current, normalize, pageSize=50, limit=DEFAULT_CONTEXT_MESSAGES):
24
24
  result = list(recentEntries(database, chatId, normalize, pageSize, limit, before=messageOrder(current)).values())
25
25
  while result and len(json.dumps(result, ensure_ascii=False).encode('utf-8')) > MAX_CONTEXT_BYTES:
26
26
  result.pop(0)
@@ -3,7 +3,7 @@ import json
3
3
  import os
4
4
  import time
5
5
  from pathlib import Path
6
- from snapshot import SnapshotBusy, checkedSnapshot, fileStamp, hasCurrentWalFrames
6
+ from snapshot import NotSent, SnapshotBusy, SnapshotTimeout, checkedSnapshot, fileStamp, hasCurrentWalFrames
7
7
  from binding import findAccountProcess, checkAccountProcess, WechatOffline
8
8
 
9
9
 
@@ -145,8 +145,9 @@ def connect(config, stateDir):
145
145
  return database.get_messages(chat['id'], limit=50)
146
146
  except SnapshotBusy:
147
147
  time.sleep(0.2)
148
- raise RuntimeError('未能获取稳定的目标聊天快照,请人工核对本次发送')
149
- before = {(row['shard'], row['local_id']) for row in stableRows(time.monotonic() + 20)}
148
+ raise SnapshotTimeout('未能获取稳定的目标聊天快照,请人工核对本次发送')
149
+ try: before = {(row['shard'], row['local_id']) for row in stableRows(time.monotonic() + 20)}
150
+ except SnapshotTimeout as error: raise NotSent(str(error)) from error # nothing has been sent yet
150
151
  getSender().send(chat['id'], kind, value)
151
152
  digest = None
152
153
  if kind != 1:
@@ -3,6 +3,7 @@ import hashlib
3
3
  import json
4
4
  import shutil
5
5
  from pathlib import Path
6
+ from snapshot import NotSent
6
7
 
7
8
 
8
9
  def serverConfirmed(row):
@@ -34,14 +35,15 @@ def prepareMessages(messages, config, stateDir):
34
35
  if kind not in ('image', 'sticker'):
35
36
  raise ValueError('只支持文字、图片和表情包')
36
37
  if kind == 'sticker' and message.get('stickerId'):
37
- root = Path(config['stickerDir']).resolve()
38
- catalog = json.loads((root / 'catalog.json').read_text(encoding='utf-8-sig'))
39
- sticker = next((sticker for pack in catalog['packs'] for sticker in pack['stickers']
40
- if sticker['id'] == message['stickerId']), None)
38
+ root = (Path(config['moduleFile']).parent / 'stickers').resolve()
39
+ try: stickers = json.loads((root / 'index.json').read_text(encoding='utf-8-sig'))['stickers']
40
+ except (OSError, ValueError, KeyError, TypeError) as error:
41
+ raise ValueError('表情库还没有可用的索引') from error
42
+ sticker = next((sticker for sticker in stickers if sticker.get('id') == message['stickerId']), None)
41
43
  if not sticker:
42
- raise ValueError('表情包 ID 不在现有目录中')
43
- path = (root / 'assets' / sticker['file']).resolve()
44
- if not path.is_relative_to(root / 'assets'):
44
+ raise ValueError('表情包 ID 不在表情库中,请先用 sticker_search 查')
45
+ path = (root / 'files' / str(sticker.get('file'))).resolve()
46
+ if not path.is_relative_to(root / 'files'):
45
47
  raise ValueError('表情包路径越界')
46
48
  else:
47
49
  if not isinstance(message.get('path'), str) or not Path(message['path']).is_absolute():
@@ -89,6 +91,11 @@ def deliverMessages(messages, send, confirmed=None):
89
91
  for index, message in enumerate(messages):
90
92
  try:
91
93
  send(message)
94
+ except NotSent:
95
+ # Nothing of this reply has reached WeChat yet, so the whole job may be tried again; once an earlier
96
+ # message went out, a later one that did not start is part of a half-sent reply and stays uncertain.
97
+ if index == 0: raise
98
+ raise RuntimeError(f'第 {index+1}/{len(messages)} 条发送未开始;停止整批,不重发已处理消息')
92
99
  except Exception as error:
93
100
  raise RuntimeError(f'第 {index+1}/{len(messages)} 条发送未确认;停止整批,不重发已处理消息:{error}') from error
94
101
  if confirmed:
@@ -1,15 +1,70 @@
1
1
  """Read-only group history; cursors identify the oldest returned row, not a shifting offset."""
2
2
  import base64
3
+ import hashlib
3
4
  import json
5
+ from pathlib import Path
4
6
  from messages import MAX_CONTEXT_BYTES, displayMessage, messageEntry, messageNormalizer, recentEntries
7
+ from incoming import ImageUnavailable
5
8
  from quotes import isAppMessage
9
+ from stickers import StickerUnavailable, fetchSticker, stickerKind
10
+
11
+
12
+ def readHistoryImage(database, config, request, chat):
13
+ """One picture of a configured group, named by the message ID that `readHistory` returned for it."""
14
+ imageId = request['imageId']
15
+ shard, _, localId = imageId.rpartition(':') if isinstance(imageId, str) and len(imageId) <= 256 else ('', '', '')
16
+ if not shard or not localId.isdigit():
17
+ raise ValueError('图片消息 ID 无效')
18
+ raw = database.get_message_row(chat['id'], int(localId), local_type=3, shard=shard)
19
+ if not raw or (raw.get('local_type') or 0) & 0xffffffff != 3:
20
+ raise ValueError('这个 ID 不是本群里的图片消息')
21
+ row = dict(local_id=int(localId), shard=shard, sort_seq=int(raw.get('sort_seq') or 0), create_time=int(raw.get('create_time') or 0),
22
+ sender_username=raw.get('sender_username'), type='图片', content='【群图片】')
23
+ message = displayMessage({**row, 'type': '文本'}, messageNormalizer(database, chat, config))
24
+ if not message:
25
+ raise ValueError('这张图片的消息无法显示')
26
+ entry = messageEntry(row, message)
27
+ try: entry['image'] = database.readImage(chat['id'], row)
28
+ except ImageUnavailable as error: raise ValueError(f'图片未读取:{error}') from error
29
+ return dict(messages=[entry])
30
+
31
+
32
+ def saveReceivedSticker(database, config, request):
33
+ """Fetch the original of a sticker someone sent (private or group) and stage it for the host's sticker library."""
34
+ chatId, accountId = request.get('chatId'), request.get('accountId')
35
+ if accountId != config['selfId'] or not any(chat['id'] == chatId for chat in config['chats']):
36
+ raise ValueError('读取目标不属于绑定账号的已配置聊天')
37
+ if not config.get('fetchStickers', True):
38
+ raise ValueError('未开启表情包下载(fetchStickers 为 false)')
39
+ messageId = request['stickerId']
40
+ shard, _, localId = messageId.rpartition(':') if isinstance(messageId, str) and len(messageId) <= 256 else ('', '', '')
41
+ if not shard or not localId.isdigit():
42
+ raise ValueError('表情包消息 ID 无效')
43
+ raw = database.get_message_row(chatId, int(localId), shard=shard)
44
+ if not raw or (raw.get('local_type') or 0) & 0xffffffff not in (47, 11000):
45
+ raise ValueError('这个 ID 不是聊天里的表情包消息')
46
+ try:
47
+ data = fetchSticker(raw['content'])
48
+ extension, mediaType = stickerKind(data)
49
+ except StickerUnavailable as error:
50
+ raise ValueError(str(error)) from error
51
+ digest = hashlib.md5(data).hexdigest()
52
+ staging = Path(config['moduleFile']).parent / 'stickers' / 'incoming'
53
+ staging.mkdir(parents=True, exist_ok=True)
54
+ name = f'{digest}.{extension}'
55
+ (staging / name).write_bytes(data)
56
+ return dict(sticker=dict(md5=digest, file=name, mediaType=mediaType, bytes=len(data)))
6
57
 
7
58
 
8
59
  def readHistory(database, config, request):
60
+ if 'stickerId' in request:
61
+ return saveReceivedSticker(database, config, request)
9
62
  chatId, accountId = request.get('chatId'), request.get('accountId')
10
63
  chat = next((chat for chat in config['chats'] if chat['id'] == chatId), None)
11
64
  if accountId != config['selfId'] or not chat or not chatId.endswith('@chatroom'):
12
65
  raise ValueError('读取目标不属于绑定账号的已配置群聊')
66
+ if 'imageId' in request:
67
+ return readHistoryImage(database, config, request, chat)
13
68
  limit = request.get('limit', 10)
14
69
  if type(limit) is not int or not 1 <= limit <= 20:
15
70
  raise ValueError('群消息分页数量必须为 1–20')
@@ -30,7 +85,9 @@ def readHistory(database, config, request):
30
85
  def historyEntry(row):
31
86
  if row['type'] == '系统消息': return None
32
87
  text = str(row['content'])
33
- if row['type'] != '文本' and not isAppMessage(row): text = f"【{row['type']},未读取内容】"
88
+ if row['type'] == '图片': text = '【图片,未读取;要看请调用 wechat_image,messageId 用本条的 id】'
89
+ elif row['type'] == '动画表情': text = '【表情包,未读取;想收进表情库就调用 sticker_save,messageId 用本条的 id】'
90
+ elif row['type'] != '文本' and not isAppMessage(row): text = f"【{row['type']},未读取内容】"
34
91
  elif len(text) > 2000: text = '【文本超过 2000 字,未读取正文】'
35
92
  message = displayMessage(row, normalize) if isAppMessage(row) else normalize({**row, 'type': '文本', 'content': text}, includeSelf=True)
36
93
  if not message and isAppMessage(row):
@@ -8,6 +8,14 @@ class SnapshotBusy(RuntimeError):
8
8
  pass
9
9
 
10
10
 
11
+ class SnapshotTimeout(RuntimeError):
12
+ """No stable snapshot of the target chat could be read in time."""
13
+
14
+
15
+ class NotSent(SnapshotBusy):
16
+ """A send gave up before anything reached WeChat, so the same job can safely be tried again."""
17
+
18
+
11
19
  def fileStamp(path):
12
20
  try:
13
21
  stat = Path(path).stat()