openclaw-zalo-mod 2.31.1 → 2.32.2
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.
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/dashboard.css +6 -0
- package/dashboard.js +22 -2
- package/index.html +1 -1
- package/index.js +102 -6
- package/openclaw.plugin.json +2 -2
- package/package.json +1 -1
- package/src/agent/tool-surface.js +39 -3
- package/src/integration/openclaw-adapter.js +42 -6
- package/src/integration/zalo-mod-engine.js +10 -1
- package/src/messaging/self-origin-tracker.js +145 -0
- package/src/storage/database.js +6 -4
- package/src/storage/migrations.js +17 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
[](https://openclaw.ai)
|
|
7
|
-
[](./CHANGELOG.md)
|
|
8
8
|
|
|
9
9
|
**[🇺🇸 English](./README.md)**
|
|
10
10
|
|
package/README.vi.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
[](https://openclaw.ai)
|
|
7
|
-
[](./CHANGELOG.md)
|
|
8
8
|
|
|
9
9
|
**[🇺🇸 English](./README.md)**
|
|
10
10
|
|
package/dashboard.css
CHANGED
|
@@ -5823,6 +5823,12 @@ textarea:focus {
|
|
|
5823
5823
|
}
|
|
5824
5824
|
.chat-msg.me .chat-bubble { background: var(--primary-soft); border-color: color-mix(in srgb, var(--primary) 30%, transparent); }
|
|
5825
5825
|
.chat-msg-time { font-size: 10.5px; color: var(--muted); margin-top: 2px; padding: 0 4px; }
|
|
5826
|
+
/* Nhãn nguồn tin đi ra: bot soạn hay người tự gõ. Cố ý nhỏ và nhạt hơn cả giờ gửi - đây là thông
|
|
5827
|
+
tin tra cứu khi cần, không phải thứ phải đọc ở mỗi dòng. */
|
|
5828
|
+
.chat-origin { display: inline-block; margin-right: 5px; padding: 0 5px; border-radius: 999px;
|
|
5829
|
+
font-size: 9.5px; line-height: 15px; font-weight: 600; letter-spacing: .02em; vertical-align: 1px; }
|
|
5830
|
+
.chat-origin.is-bot { background: rgba(37, 99, 235, .12); color: #2563eb; }
|
|
5831
|
+
.chat-origin.is-human { background: rgba(100, 116, 139, .14); color: #64748b; }
|
|
5826
5832
|
.chat-media { max-width: 220px; max-height: 220px; border-radius: 8px; display: block; margin-bottom: 6px; }
|
|
5827
5833
|
|
|
5828
5834
|
.chat-composer { flex: none; display: flex; gap: 8px; padding: 10px; border-top: 1px solid var(--line); align-items: flex-end; }
|
package/dashboard.js
CHANGED
|
@@ -10,7 +10,7 @@ const modalBody = document.getElementById('modalBody');
|
|
|
10
10
|
const modalCancel = document.getElementById('modalCancel');
|
|
11
11
|
const modalConfirm = document.getElementById('modalConfirm');
|
|
12
12
|
const token = window.ZALO_DASHBOARD_TOKEN || '';
|
|
13
|
-
const pluginVersion = '2.
|
|
13
|
+
const pluginVersion = '2.32.2';
|
|
14
14
|
let state = null;
|
|
15
15
|
let activeGroupId = '';
|
|
16
16
|
let lang = localStorage.getItem('zaloDashboardLang') || 'vi';
|
|
@@ -7056,6 +7056,26 @@ function chatConvRowHtml(c) {
|
|
|
7056
7056
|
</button>`;
|
|
7057
7057
|
}
|
|
7058
7058
|
|
|
7059
|
+
/**
|
|
7060
|
+
* Nhãn "ai viết câu này" cho tin ĐI RA.
|
|
7061
|
+
*
|
|
7062
|
+
* Bot Zalo cá nhân gửi bằng chính tài khoản của khách, nên bong bóng bên phải có thể là bot viết
|
|
7063
|
+
* HOẶC chủ máy tự gõ trên điện thoại - nhìn không thể phân biệt. Trước đây muốn biết phải SSH vào
|
|
7064
|
+
* máy chủ soi log xem có lượt gọi model quanh mốc đó không.
|
|
7065
|
+
*
|
|
7066
|
+
* Không có nhãn (tin cũ ghi trước bản 2.32, hoặc host chưa phát reply_payload_sending) thì KHÔNG
|
|
7067
|
+
* vẽ gì - im lặng đúng hơn là đoán bừa rồi nói sai với khách.
|
|
7068
|
+
*/
|
|
7069
|
+
function chatOriginBadge(m) {
|
|
7070
|
+
if (!m.fromSelf || (m.origin !== 'bot' && m.origin !== 'human')) return '';
|
|
7071
|
+
const bot = m.origin === 'bot';
|
|
7072
|
+
const label = bot ? 'Bot' : 'Tự gõ';
|
|
7073
|
+
const title = bot
|
|
7074
|
+
? 'Do bot soạn và gửi'
|
|
7075
|
+
: 'Người dùng tự gõ từ tài khoản này (không phải bot)';
|
|
7076
|
+
return `<span class="chat-origin ${bot ? 'is-bot' : 'is-human'}" title="${crmEsc(title)}">${crmEsc(label)}</span>`;
|
|
7077
|
+
}
|
|
7078
|
+
|
|
7059
7079
|
/** HTML một tin — dùng chung cho vẽ lại toàn bộ và cho việc CHÈN THÊM tin mới. */
|
|
7060
7080
|
function chatMsgHtml(m, { isGroup, lastDay, isNew = false }) {
|
|
7061
7081
|
const day = new Date(Number(m.sentAt)).toLocaleDateString('vi-VN');
|
|
@@ -7068,7 +7088,7 @@ function chatMsgHtml(m, { isGroup, lastDay, isNew = false }) {
|
|
|
7068
7088
|
${/* Co anh roi thi khong in kem chu "[Media attachment]" — day la chu do host sinh ra khi
|
|
7069
7089
|
tin khong co phan chu, giu lai chi lam bong bong roi. */''}
|
|
7070
7090
|
<div class="chat-bubble">${media}${crmEsc(media && /^\[(media|file|image|sticker)[^\]]*\]$/i.test((m.text || '').trim()) ? '' : (m.text || ''))}</div>
|
|
7071
|
-
<div class="chat-msg-time">${chatTime(m.sentAt)}</div>
|
|
7091
|
+
<div class="chat-msg-time">${chatOriginBadge(m)}${chatTime(m.sentAt)}</div>
|
|
7072
7092
|
</div>`;
|
|
7073
7093
|
return { html, day };
|
|
7074
7094
|
}
|
package/index.html
CHANGED
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
log.</span>
|
|
128
128
|
</div>
|
|
129
129
|
<div class="plugin-meta">
|
|
130
|
-
<strong>zalo-mod <span id="pluginVersion">v2.
|
|
130
|
+
<strong>zalo-mod <span id="pluginVersion">v2.32.2</span></strong>
|
|
131
131
|
<span>Được làm ❤️ bởi tuanminhole</span>
|
|
132
132
|
<div class="socials" aria-label="Author links">
|
|
133
133
|
<a href="https://www.facebook.com/holeminhtuan.it/" target="_blank" rel="noreferrer"
|
package/index.js
CHANGED
|
@@ -77,6 +77,30 @@ function getOpenclawJsonPath() {
|
|
|
77
77
|
return path.join(_openclawHome, 'openclaw.json');
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Bot "mặc định" của zalo-mod = tài khoản zalo-connect mà openclaw coi là mặc định
|
|
82
|
+
* (`channels.zalo-connect.defaultAccount`), KHÔNG phải chuỗi cứng 'default'.
|
|
83
|
+
*
|
|
84
|
+
* Ca thật 16/09/2026 (mac_dr-tuan): tài khoản `default` mất phiên Zalo, chủ bot chuyển sang `bstuana`
|
|
85
|
+
* và openclaw đã ghi `defaultAccount: "bstuana"`, nhưng bot gọi `get-friends` vẫn nhận "đăng nhập thất
|
|
86
|
+
* bại" vì mọi action không kèm `profile` và không thuộc nhóm nào đều rơi về 'default'. Đọc thẳng từ
|
|
87
|
+
* openclaw.json để hai bên cùng một câu trả lời; đệm 5 giây vì hàm này nằm trên đường nóng của từng
|
|
88
|
+
* action. Thiếu file / JSON hỏng / không khai → 'default' đúng như hành vi cũ.
|
|
89
|
+
*/
|
|
90
|
+
let _defaultProfileCache = { at: 0, value: 'default' };
|
|
91
|
+
function resolveDefaultProfile() {
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
if (now - _defaultProfileCache.at < 5000) return _defaultProfileCache.value;
|
|
94
|
+
let value = 'default';
|
|
95
|
+
try {
|
|
96
|
+
const cfg = JSON.parse(readFileSync(getOpenclawJsonPath(), 'utf8'));
|
|
97
|
+
const acc = cfg?.channels?.['zalo-connect']?.defaultAccount;
|
|
98
|
+
if (typeof acc === 'string' && acc.trim()) value = acc.trim();
|
|
99
|
+
} catch (_) { /* giữ 'default' */ }
|
|
100
|
+
_defaultProfileCache = { at: now, value };
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
80
104
|
/**
|
|
81
105
|
* Read bot name from IDENTITY.md in workspace dir.
|
|
82
106
|
* Parses `**Tên:** BotName` pattern.
|
|
@@ -90,6 +114,22 @@ async function _readBotNameFromIdentity(workspaceDir) {
|
|
|
90
114
|
} catch { return null; }
|
|
91
115
|
}
|
|
92
116
|
|
|
117
|
+
/**
|
|
118
|
+
* openclaw 2026.8.x chuyển agents.list (mảng) thành agents.entries (object theo id) và schema
|
|
119
|
+
* mới CẤM `list` nằm trong file. Đọc được CẢ HAI dạng — thiếu nhánh entries thì trên 2026.8
|
|
120
|
+
* plugin tưởng project không có agent nào: workspace resolve về mặc định sai chỗ, binding
|
|
121
|
+
* zalo-connect không tự gắn, skill cài lạc thư mục (đo 02/09/2026 trên vps_c-thu).
|
|
122
|
+
*/
|
|
123
|
+
function agentListFromConfig(config) {
|
|
124
|
+
const ag = config?.agents || {};
|
|
125
|
+
if (Array.isArray(ag.list) && ag.list.length) return ag.list;
|
|
126
|
+
const entries = ag.entries;
|
|
127
|
+
if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
|
|
128
|
+
return Object.entries(entries).map(([id, v]) => ({ id, ...(v && typeof v === 'object' ? v : {}) }));
|
|
129
|
+
}
|
|
130
|
+
return Array.isArray(ag.list) ? ag.list : [];
|
|
131
|
+
}
|
|
132
|
+
|
|
93
133
|
|
|
94
134
|
/**
|
|
95
135
|
* Auto-patch openclaw.json — chỉ đảm bảo entry có `enabled` + `hooks` (+ bindings/channels).
|
|
@@ -157,7 +197,7 @@ async function _patchOpenclawConfig(openclawHome, patch, logger, force = false)
|
|
|
157
197
|
// Extra keys in openclaw.json are harmless; losing user config is not.
|
|
158
198
|
|
|
159
199
|
// Auto-provision bindings: ensure Zalo Connect is bound to an agent.
|
|
160
|
-
const agentId = config
|
|
200
|
+
const agentId = agentListFromConfig(config)[0]?.id;
|
|
161
201
|
if (agentId && !Array.isArray(config.bindings)) {
|
|
162
202
|
config.bindings = [{ agentId, match: { channel: 'zalo-connect' } }];
|
|
163
203
|
changed = true;
|
|
@@ -730,7 +770,7 @@ const plugin = definePluginEntry({
|
|
|
730
770
|
try {
|
|
731
771
|
const raw = await fs.readFile(getOpenclawJsonPath(), 'utf8');
|
|
732
772
|
const config = JSON.parse(raw);
|
|
733
|
-
const agents = config
|
|
773
|
+
const agents = agentListFromConfig(config);
|
|
734
774
|
const bindings = config?.bindings || [];
|
|
735
775
|
const zaloConnectAccounts = config?.channels?.['zalo-connect']?.accounts || {};
|
|
736
776
|
|
|
@@ -1072,7 +1112,10 @@ const plugin = definePluginEntry({
|
|
|
1072
1112
|
return String(profileStr ?? 'default').split(',').map(s => s.trim()).filter(Boolean);
|
|
1073
1113
|
}
|
|
1074
1114
|
function primaryProfile(profileStr) {
|
|
1075
|
-
|
|
1115
|
+
// Rỗng/undefined → bot mặc định THẬT của openclaw, không để parseProfiles đổi thành 'default'.
|
|
1116
|
+
const raw = profileStr == null ? '' : String(profileStr).trim();
|
|
1117
|
+
if (!raw) return resolveDefaultProfile();
|
|
1118
|
+
return parseProfiles(raw)[0] || resolveDefaultProfile();
|
|
1076
1119
|
}
|
|
1077
1120
|
function mergeProfileStr(existing, prof) {
|
|
1078
1121
|
// KHÔNG dùng parseProfiles(existing) vì nó fallback undefined→'default',
|
|
@@ -1151,7 +1194,7 @@ const plugin = definePluginEntry({
|
|
|
1151
1194
|
|
|
1152
1195
|
|
|
1153
1196
|
// Workspace + Memory dir — resolve from agent config or OPENCLAW_HOME
|
|
1154
|
-
const _agentWorkspace = cfg
|
|
1197
|
+
const _agentWorkspace = agentListFromConfig(cfg)[0]?.workspace;
|
|
1155
1198
|
const _defaultWorkspace = cfg?.agents?.defaults?.workspace;
|
|
1156
1199
|
const workspaceDir = String(
|
|
1157
1200
|
_agentWorkspace
|
|
@@ -1170,7 +1213,7 @@ const plugin = definePluginEntry({
|
|
|
1170
1213
|
if (!raw) return '';
|
|
1171
1214
|
return path.isAbsolute(raw) ? raw : path.resolve(_openclawHome, raw);
|
|
1172
1215
|
};
|
|
1173
|
-
for (const agent of (cfg
|
|
1216
|
+
for (const agent of agentListFromConfig(cfg)) {
|
|
1174
1217
|
const dir = resolveWs(agent?.workspace);
|
|
1175
1218
|
if (dir) dirs.add(dir);
|
|
1176
1219
|
}
|
|
@@ -1179,6 +1222,35 @@ const plugin = definePluginEntry({
|
|
|
1179
1222
|
return [...dirs];
|
|
1180
1223
|
}
|
|
1181
1224
|
|
|
1225
|
+
// Model yeu (smart-route) hay choi "em khong co cong cu" du 6 tool zalo_mod_* LUON dang
|
|
1226
|
+
// ky voi gateway — do 02/09/2026 (bot "Tro Ly Co Minh Thu"): owner nhan "dong bo lai di"
|
|
1227
|
+
// va bot doi owner tu bam nut Sync Account. Ghim su that vao TOOLS.md cua tung workspace
|
|
1228
|
+
// — noi moi model doc moi phien — thi cung cau hoi no goi tool va sync duoc ngay.
|
|
1229
|
+
// Idempotent theo marker; khong chan boot khi ghi loi.
|
|
1230
|
+
const TOOLS_GUIDE_MARKER = '## \u{1F6E0}\u{FE0F} Zalo Mod \u2014 c\u00F4ng c\u1EE5 LU\u00D4N c\u00F3 s\u1EB5n';
|
|
1231
|
+
async function bootstrapToolsGuide() {
|
|
1232
|
+
const guide = `\n\n${TOOLS_GUIDE_MARKER}\n\n` +
|
|
1233
|
+
'B\u1EA1n LU\u00D4N c\u00F3 6 tool: `zalo_mod_groups`, `zalo_mod_settings`, `zalo_mod_history`,\n' +
|
|
1234
|
+
'`zalo_mod_reports`, `zalo_mod_tasks`, `zalo_mod_action`. Ch\u00FAng \u0111\u0103ng k\u00FD s\u1EB5n trong\n' +
|
|
1235
|
+
'gateway \u2014 n\u1EBFu b\u1EA1n "kh\u00F4ng th\u1EA5y", \u0111\u00F3 l\u00E0 b\u1EA1n nh\u1EA7m; h\u00E3y C\u1EE8 G\u1ECCI tool.\n\n' +
|
|
1236
|
+
'- Owner b\u1EA3o "\u0111\u1ED3ng b\u1ED9 / sync nh\u00F3m / sync account" \u2192 g\u1ECDi `zalo_mod_action` v\u1EDBi\n' +
|
|
1237
|
+
' `{"action": "sync-groups"}` \u2014 t\u01B0\u01A1ng \u0111\u01B0\u01A1ng n\u00FAt Sync Account tr\u00EAn dashboard.\n' +
|
|
1238
|
+
'- H\u1ECFi danh s\u00E1ch nh\u00F3m/quy\u1EC1n nh\u00F3m \u2192 `zalo_mod_groups`. C\u00E0i \u0111\u1EB7t/t\u00EAn g\u1ECDi \u2192 `zalo_mod_settings`.\n' +
|
|
1239
|
+
'- TUY\u1EC6T \u0110\u1ED0I kh\u00F4ng tr\u1EA3 l\u1EDDi "em kh\u00F4ng c\u00F3 c\u00F4ng c\u1EE5 trong phi\u00EAn n\u00E0y" \u2014 sai s\u1EF1 th\u1EADt v\u00E0\n' +
|
|
1240
|
+
' l\u00E0m owner m\u1EA5t c\u00F4ng b\u1EA5m tay.\n';
|
|
1241
|
+
for (const dir of agentWorkspaceDirs()) {
|
|
1242
|
+
try {
|
|
1243
|
+
const f = path.join(dir, 'TOOLS.md');
|
|
1244
|
+
let cur = '';
|
|
1245
|
+
try { cur = await fs.readFile(f, 'utf8'); } catch { }
|
|
1246
|
+
if (cur.includes(TOOLS_GUIDE_MARKER)) continue;
|
|
1247
|
+
await fs.appendFile(f, guide);
|
|
1248
|
+
logger.info(`[openclaw-zalo-mod] ghi h\u01B0\u1EDBng d\u1EABn tool zalo_mod_* v\u00E0o ${f}`);
|
|
1249
|
+
} catch { }
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
bootstrapToolsGuide().catch(() => { });
|
|
1253
|
+
|
|
1182
1254
|
/**
|
|
1183
1255
|
* Host đã publish skill native của plugin chưa? Nếu rồi thì KHÔNG ghi bản
|
|
1184
1256
|
* fallback vào workspace (tránh 2 skill trùng nội dung, và tránh bản
|
|
@@ -4604,6 +4676,18 @@ Quy tắc:
|
|
|
4604
4676
|
byName.set(key, seed(group));
|
|
4605
4677
|
continue;
|
|
4606
4678
|
}
|
|
4679
|
+
// CHỈ gộp khi hai bản ghi đến từ BOT KHÁC NHAU. Cùng một bot mà có 2 ID trùng tên
|
|
4680
|
+
// nghĩa là 2 nhóm THẬT trùng tên (đo 02/09/2026, c Minh Thư: "Tài Liệu" vs
|
|
4681
|
+
// "tài liệu") — gộp là UI nuốt mất một nhóm trong khi store vẫn 30, bot đếm 30
|
|
4682
|
+
// mà màn hình chỉ 29, owner tưởng đồng bộ sai.
|
|
4683
|
+
const _exProfs = parseProfiles(existing.profile);
|
|
4684
|
+
const _gProfs = parseProfiles(group.profile);
|
|
4685
|
+
const _sharesBot = _gProfs.some((p) => _exProfs.includes(p))
|
|
4686
|
+
|| (_gProfs.length === 0 && _exProfs.length === 0);
|
|
4687
|
+
if (_sharesBot) {
|
|
4688
|
+
byName.set(`id:${group.groupId}`, seed(group));
|
|
4689
|
+
continue;
|
|
4690
|
+
}
|
|
4607
4691
|
// Cùng tên = cùng nhóm vật lý (Zalo cấp ID per-account khác nhau cho mỗi bot).
|
|
4608
4692
|
// HỢP profile của tất cả bản trùng để badge hiện đủ bot; giữ entry chất lượng cao làm đại diện.
|
|
4609
4693
|
const prof = primaryProfile(group.profile);
|
|
@@ -4909,7 +4993,7 @@ Quy tắc:
|
|
|
4909
4993
|
|
|
4910
4994
|
// Determine the profile — group nhiều bot lưu CSV "default,mkt", phải lấy
|
|
4911
4995
|
// primary (bot đầu) làm profile hợp lệ để gọi withZaloApi, không dùng nguyên chuỗi.
|
|
4912
|
-
let targetProfile =
|
|
4996
|
+
let targetProfile = resolveDefaultProfile();
|
|
4913
4997
|
if (payload.profile) {
|
|
4914
4998
|
targetProfile = payload.profile;
|
|
4915
4999
|
} else if (groupId && groupNames[groupId]?.profile) {
|
|
@@ -6044,6 +6128,9 @@ Quy tắc:
|
|
|
6044
6128
|
text: m.text,
|
|
6045
6129
|
sentAt: Number(m.sent_at) || 0,
|
|
6046
6130
|
fromSelf: !!m.from_self,
|
|
6131
|
+
// 'bot' | 'human' cho tin đi ra; undefined cho tin của người khác và cho
|
|
6132
|
+
// tin ghi trước bản 2.32 (không có dữ liệu thì không gắn nhãn, đừng đoán).
|
|
6133
|
+
origin: m.origin === 'bot' || m.origin === 'human' ? m.origin : undefined,
|
|
6047
6134
|
media: m.media_json ? JSON.parse(m.media_json) : undefined,
|
|
6048
6135
|
})),
|
|
6049
6136
|
};
|
|
@@ -6727,7 +6814,16 @@ Quy tắc:
|
|
|
6727
6814
|
|
|
6728
6815
|
api.on('reply_payload_sending', (event, ctx) => {
|
|
6729
6816
|
if ((ctx?.channelId || event?.channel) !== 'zalo-connect') return;
|
|
6817
|
+
// Ghi vân tay TRƯỚC khi trang trí mention: tin quay về qua bridge đã bị zalo-connect
|
|
6818
|
+
// viết lại phần mention, nên bản so khớp phải là bản chưa trang trí (fingerprint()
|
|
6819
|
+
// cũng tự cắt mention đầu câu, hai lớp phòng cho chắc). Nhờ dấu này mà khung chat
|
|
6820
|
+
// phân biệt được câu nào bot viết, câu nào chủ máy gõ tay từ cùng tài khoản đó.
|
|
6730
6821
|
const result = replyMentions.decorate(event, ctx);
|
|
6822
|
+
// Ghi dấu CẢ hai bản: bản OpenClaw đưa xuống và bản đã gắn mention thực sự gửi đi.
|
|
6823
|
+
// Tin quay về từ Zalo mang mention native (`@Kent ` một khoảng trắng), không khớp bản
|
|
6824
|
+
// gốc - đo trên máy khách 13/09/2026, mọi câu trả lời trong nhóm đều bị gán nhầm
|
|
6825
|
+
// "Tự gõ" vì chỉ ghi bản gốc. Hai bản trỏ về một dấu nên không sinh dấu thừa.
|
|
6826
|
+
try { zEngine.selfOrigin?.remember?.(event?.payload?.text || '', result?.text || ''); } catch { }
|
|
6731
6827
|
if (!result) return;
|
|
6732
6828
|
if (result.changed) {
|
|
6733
6829
|
logger.info(`[openclaw-zalo-mod] native reply mention: @${result.record.senderName} run=${result.record.runId || 'session-fifo'}`);
|
package/openclaw.plugin.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "OpenClaw Zalo Mod",
|
|
4
4
|
"description": "Zero-token Zalo group moderation — slash commands, anti-spam, warn system, memory integration. Blocks LLM for non-mention messages.",
|
|
5
5
|
"icon": "https://cdn.simpleicons.org/zalo",
|
|
6
|
-
"version": "2.
|
|
6
|
+
"version": "2.32.2",
|
|
7
7
|
"enabledByDefault": false,
|
|
8
8
|
"activation": {
|
|
9
9
|
"onStartup": true,
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"allowDestructive": {
|
|
71
71
|
"type": "boolean",
|
|
72
72
|
"default": false,
|
|
73
|
-
"description": "Cho bot chạy action không hoàn tác được (kick, block, rời nhóm,
|
|
73
|
+
"description": "Cho bot chạy action không hoàn tác được (kick, block, rời nhóm, kết bạn HÀNG LOẠT) khi owner ra lệnh. Mặc định tắt. Gửi lời mời kết bạn từng người không cần cờ này."
|
|
74
74
|
}
|
|
75
75
|
},
|
|
76
76
|
"additionalProperties": false
|
package/package.json
CHANGED
|
@@ -41,6 +41,13 @@ export const AGENT_SAFE_ACTIONS = Object.freeze([
|
|
|
41
41
|
'get-permissions', 'get-name-triggers', 'group-detail', 'journal-data',
|
|
42
42
|
'scan-members', 'sync-groups', 'generate-summary', 'send-message',
|
|
43
43
|
'toggle-setting', 'bulk-toggle-setting',
|
|
44
|
+
// Gửi lời mời kết bạn TỪNG NGƯỜI (16/09/2026 - ca thật trên bot "Bs Tuấn A"): owner nhờ kết bạn với
|
|
45
|
+
// thành viên nhóm, bot đọc `destructiveEnabled: false` rồi trả lời "hệ thống chặn". Thao tác này
|
|
46
|
+
// hoàn tác được (`undo-friend-request`) và đường `zalo-api` trong connect-actions.js đã xếp nó là
|
|
47
|
+
// WRITE - hai bảng phải nói cùng một luật. Chỉ bản HÀNG LOẠT (`bulk-friend-request`) mới cần
|
|
48
|
+
// allowDestructive, vì kết bạn ồ ạt là thứ làm Zalo khoá tài khoản bot. Action này nhận MỘT
|
|
49
|
+
// `userId` (index.js), muốn nhiều người bot phải gọi từng lượt và luật gói vẫn đếm đích như cũ.
|
|
50
|
+
'send-friend-request',
|
|
44
51
|
// Lịch báo cáo — bot ĐỌC + TẠO + SỬA được, để owner sai bằng lời thay vì tự vào dashboard.
|
|
45
52
|
// `report-digest-preview` chỉ dựng chuỗi (không gửi) nên an toàn. `report-job-run` GỬI thật nhưng
|
|
46
53
|
// chỉ tới các đích đã cấu hình sẵn trong lịch, không nhận đích tuỳ ý từ agent.
|
|
@@ -87,7 +94,8 @@ export const AGENT_SAFE_ACTIONS = Object.freeze([
|
|
|
87
94
|
*/
|
|
88
95
|
export const AGENT_DESTRUCTIVE_ACTIONS = Object.freeze([
|
|
89
96
|
'remove-user', 'block-member', 'unblock-member', 'leave-group',
|
|
90
|
-
'
|
|
97
|
+
'accept-friend', 'reject-friend', 'review-pending',
|
|
98
|
+
// `send-friend-request` (từng người) đã chuyển sang SAFE - xem ghi chú ở đó. Bản hàng loạt vẫn ở đây.
|
|
91
99
|
'send-messages', 'bulk-friend-request',
|
|
92
100
|
]);
|
|
93
101
|
|
|
@@ -189,7 +197,29 @@ export function isOwnerRequester(requesterSenderId, ownerIds) {
|
|
|
189
197
|
*/
|
|
190
198
|
export function isTrustedOwnerContext(toolContext, ownerIds) {
|
|
191
199
|
if (toolContext?.senderIsOwner === true) return true;
|
|
192
|
-
|
|
200
|
+
if (isOwnerRequester(toolContext?.requesterSenderId, ownerIds)) return true;
|
|
201
|
+
return isOwnerScheduledRun(toolContext);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Lượt do CRON của chính owner chạy - không có người gửi, và đó là bình thường.
|
|
206
|
+
*
|
|
207
|
+
* Đo trên máy khách 15/09/2026: lịch báo cáo doanh số 22:00 chạy bằng cron, `requesterSenderId`
|
|
208
|
+
* rỗng và `senderIsOwner` không được cấp, nên gate ở trên trả false và plugin đưa ra 0 tool. Bot
|
|
209
|
+
* mất `zalo_mod_action`, không còn đường nào đọc lịch sử chat, rồi **bịa ra một mã lỗi nghe hợp lý**
|
|
210
|
+
* ("API lấy lịch sử chat trả HTTP 404 ở cả 4 nhóm") và gửi thẳng câu đó cho khách. Chạy lại bao
|
|
211
|
+
* nhiêu lần cũng vậy: lịch tự động KHÔNG BAO GIỜ lập nổi báo cáo. Đúng lớp lỗi mà comment của
|
|
212
|
+
* `isTrustedOwnerContext` đã cảnh báo, chỉ khác lối vào.
|
|
213
|
+
*
|
|
214
|
+
* Vì sao tin được lượt cron: job nằm trong state của chính owner, do owner đặt, và chạy trong tiến
|
|
215
|
+
* trình gateway của owner - không có đường nào cho người ngoài chen một lượt cron vào.
|
|
216
|
+
*
|
|
217
|
+
* Hai điều kiện phải ĐỦ CẢ HAI, và `!requesterSenderId` là phần cố ý: nếu một lượt cron có kèm
|
|
218
|
+
* người gửi mà người đó không phải owner thì vẫn chặn, không mở cửa theo tên session.
|
|
219
|
+
*/
|
|
220
|
+
export function isOwnerScheduledRun(toolContext) {
|
|
221
|
+
if (String(toolContext?.requesterSenderId || '').trim()) return false;
|
|
222
|
+
return /(^|:)cron:/.test(String(toolContext?.sessionKey || ''));
|
|
193
223
|
}
|
|
194
224
|
|
|
195
225
|
/**
|
|
@@ -1007,6 +1037,12 @@ export function createZaloModAgentTools(host) {
|
|
|
1007
1037
|
+ ` sessionKey=${toolContext?.sessionKey || '-'}`);
|
|
1008
1038
|
return [];
|
|
1009
1039
|
}
|
|
1010
|
-
|
|
1040
|
+
// Truyền `ownerScheduled` như một dạng "host khai là owner": `guard` bên trong mỗi tool gọi
|
|
1041
|
+
// lại `isTrustedOwnerContext` nhưng KHÔNG có `sessionKey` trong tay, nên tự nó không bao giờ
|
|
1042
|
+
// nhận ra lượt cron. Đo trên máy khách 15/09/2026: chỉ mở gate ở factory là chưa đủ - tool
|
|
1043
|
+
// được cấp nhưng mọi lời gọi vẫn chết ở guard với "requester=unknown không phải owner", và
|
|
1044
|
+
// bot lại bịa tiếp mã lỗi 404 y như cũ.
|
|
1045
|
+
const ownerScheduled = isOwnerScheduledRun(toolContext);
|
|
1046
|
+
return buildTools(requesterSenderId, toolContext?.senderIsOwner === true || ownerScheduled);
|
|
1011
1047
|
};
|
|
1012
1048
|
}
|
|
@@ -69,12 +69,47 @@ export function createOpenclawAdapter(deps = {}) {
|
|
|
69
69
|
const logger = deps.logger || console;
|
|
70
70
|
const runtime = deps.runtime || null;
|
|
71
71
|
const getConfig = deps.getConfig || (() => ({}));
|
|
72
|
+
// Tin do CHÍNH zalo-mod gửi (báo cáo định kỳ, action send-message, trả lời từ khung chat) cũng
|
|
73
|
+
// quay về qua bridge y như tin bot, nên phải ghi dấu ở đây; nếu không nó hiện nhãn "Tự gõ" và
|
|
74
|
+
// owner lại tưởng có người cầm máy nhắn.
|
|
75
|
+
const onSelfSend = typeof deps.onSelfSend === 'function' ? deps.onSelfSend : null;
|
|
72
76
|
const getService = deps.getZaloConnectService
|
|
73
77
|
|| (() => globalThis.__zaloConnectBridgeService || null);
|
|
74
78
|
let warnedDegradedMention = false;
|
|
75
79
|
let loggedBackend = '';
|
|
76
80
|
let outboundAdapterPromise = null;
|
|
77
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Đăng ký vào bridge và BÁM THEO nó. zalo-connect có thể bị cài lại/thay nóng khi gateway vẫn chạy
|
|
84
|
+
* ("Plugin replacement applied"): bản mới gán một service MỚI lên globalThis, còn listener của mình
|
|
85
|
+
* nằm trên service cũ không ai phát nữa - khung chat ngừng ghi tin trong khi bot vẫn trả lời
|
|
86
|
+
* (vps_asa, 14/10/2026). Mỗi `followMs` kiểm lại: service đổi thì huỷ đăng ký cũ, đăng ký lại vào
|
|
87
|
+
* service mới. Cũng tự bắt được bridge khi zalo-mod nạp trước zalo-connect.
|
|
88
|
+
*/
|
|
89
|
+
const followMs = Number.isFinite(deps.bridgeFollowMs) ? deps.bridgeFollowMs : 15000;
|
|
90
|
+
function followSubscription(method, cb) {
|
|
91
|
+
let current = null;
|
|
92
|
+
let off = null;
|
|
93
|
+
const attach = () => {
|
|
94
|
+
const svc = getService();
|
|
95
|
+
if (svc === current) return;
|
|
96
|
+
if (off) { try { off(); } catch { } off = null; }
|
|
97
|
+
current = svc;
|
|
98
|
+
if (svc && typeof svc[method] === 'function') {
|
|
99
|
+
off = svc[method](cb) || null;
|
|
100
|
+
if (loggedBackend) logger.info?.(`[zalo-mod] bridge ${method}: đã đăng ký vào bridge service hiện tại`);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
attach();
|
|
104
|
+
const timer = followMs > 0 ? setInterval(attach, followMs) : null;
|
|
105
|
+
timer?.unref?.();
|
|
106
|
+
return () => {
|
|
107
|
+
if (timer) clearInterval(timer);
|
|
108
|
+
if (off) { try { off(); } catch { } off = null; }
|
|
109
|
+
current = null;
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
78
113
|
function logBackendOnce(name) {
|
|
79
114
|
if (loggedBackend === name) return;
|
|
80
115
|
loggedBackend = name;
|
|
@@ -145,6 +180,10 @@ export function createOpenclawAdapter(deps = {}) {
|
|
|
145
180
|
},
|
|
146
181
|
|
|
147
182
|
async executeAction(accountId, action) {
|
|
183
|
+
// Ghi dấu trước khi gửi: tin có thể quay về trước cả khi lời gọi này trả về.
|
|
184
|
+
if (onSelfSend && OUTBOUND_TIER_ACTIONS.has(action.action)) {
|
|
185
|
+
try { onSelfSend(String(action.message ?? '')); } catch { }
|
|
186
|
+
}
|
|
148
187
|
const svc = getService();
|
|
149
188
|
if (svc?.executeAction) {
|
|
150
189
|
logBackendOnce('zalo-connect-bridge-service');
|
|
@@ -192,10 +231,7 @@ export function createOpenclawAdapter(deps = {}) {
|
|
|
192
231
|
},
|
|
193
232
|
|
|
194
233
|
subscribeInbound(cb) {
|
|
195
|
-
|
|
196
|
-
if (svc?.subscribeInbound) return svc.subscribeInbound(cb);
|
|
197
|
-
// Inbound đã đi qua hook before_dispatch của OpenClaw — không cần stream riêng.
|
|
198
|
-
return () => {};
|
|
234
|
+
return followSubscription('subscribeInbound', cb);
|
|
199
235
|
},
|
|
200
236
|
|
|
201
237
|
subscribeGroupEvents(cb) {
|
|
@@ -218,7 +254,7 @@ export function createOpenclawAdapter(deps = {}) {
|
|
|
218
254
|
subscribeHistory(cb) {
|
|
219
255
|
const svc = getService();
|
|
220
256
|
if (typeof svc?.subscribeHistory !== 'function') return null;
|
|
221
|
-
return
|
|
257
|
+
return followSubscription('subscribeHistory', cb);
|
|
222
258
|
},
|
|
223
259
|
|
|
224
260
|
supportsHistory() {
|
|
@@ -229,7 +265,7 @@ export function createOpenclawAdapter(deps = {}) {
|
|
|
229
265
|
subscribeTyping(cb) {
|
|
230
266
|
const svc = getService();
|
|
231
267
|
if (typeof svc?.subscribeTyping !== 'function') return null;
|
|
232
|
-
return
|
|
268
|
+
return followSubscription('subscribeTyping', cb);
|
|
233
269
|
},
|
|
234
270
|
};
|
|
235
271
|
}
|
|
@@ -22,6 +22,7 @@ import { openStore } from '../storage/database.js';
|
|
|
22
22
|
import { CrmStore } from '../crm/crm-store.js';
|
|
23
23
|
import { createZaloConnectBridge } from './zalo-connect-bridge.js';
|
|
24
24
|
import { createOpenclawAdapter } from './openclaw-adapter.js';
|
|
25
|
+
import { SelfOriginTracker } from '../messaging/self-origin-tracker.js';
|
|
25
26
|
|
|
26
27
|
const SWEEP_INTERVAL_MS = 60 * 1000;
|
|
27
28
|
|
|
@@ -49,13 +50,16 @@ export function createZaloModEngine({ dataDir, logger, runtime, getConfig, confi
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
const storage = openStore(path.join(dataDir, 'context.db'), { logger: log });
|
|
53
|
+
// Sổ chờ vân tay của payload bot vừa gửi, để tin quay về biết đường nhận nhãn 'bot'.
|
|
54
|
+
// Sống trong engine vì engine được cache theo dataDir: đăng ký lại plugin không mất sổ.
|
|
55
|
+
const selfOrigin = new SelfOriginTracker();
|
|
52
56
|
const buffer = new ConversationBuffer({ storage, maxPerConversation: config.bufferSize ?? 200 });
|
|
53
57
|
const turnStore = new TurnContextStore();
|
|
54
58
|
const queue = new ConversationQueue({
|
|
55
59
|
defaultTimeoutMs: config.turnTimeoutMs ?? 120_000,
|
|
56
60
|
onError: (err, meta) => log.warn?.(`[zalo-mod] turn queue error (${meta.label || meta.key}): ${err.message}`),
|
|
57
61
|
});
|
|
58
|
-
const adapter = createOpenclawAdapter({ logger: log, runtime, getConfig });
|
|
62
|
+
const adapter = createOpenclawAdapter({ logger: log, runtime, getConfig, onSelfSend: (text) => selfOrigin.remember(text) });
|
|
59
63
|
const bridge = createZaloConnectBridge(adapter, { logger: log });
|
|
60
64
|
|
|
61
65
|
// CRM core (Z4): cần SQLite thật; in-memory fallback thì CRM tắt (API trả 503).
|
|
@@ -110,6 +114,7 @@ export function createZaloModEngine({ dataDir, logger, runtime, getConfig, confi
|
|
|
110
114
|
turnStore,
|
|
111
115
|
queue,
|
|
112
116
|
crm,
|
|
117
|
+
selfOrigin,
|
|
113
118
|
|
|
114
119
|
/**
|
|
115
120
|
* Ghi một LÔ tin CŨ kéo từ Zalo về (bridge contract v5).
|
|
@@ -148,6 +153,10 @@ export function createZaloModEngine({ dataDir, logger, runtime, getConfig, confi
|
|
|
148
153
|
sentAt: ts,
|
|
149
154
|
fromSelf: !!e.fromSelf,
|
|
150
155
|
mediaUrls: e.mediaUrls,
|
|
156
|
+
// Tin đi ra từ chính tài khoản này: bot viết hay người gõ tay? Sổ chờ giữ
|
|
157
|
+
// vân tay của payload mà OpenClaw vừa gửi; khớp thì là bot. Không có
|
|
158
|
+
// tracker (host cũ) thì để NULL - thà không có nhãn còn hơn nhãn sai.
|
|
159
|
+
origin: e.fromSelf ? (selfOrigin?.claim?.(e.text || '') ?? null) : null,
|
|
151
160
|
});
|
|
152
161
|
// Mốc hội thoại lấy tin MỚI NHẤT trong lô — kéo lịch sử toàn tin cũ, ghi nhầm
|
|
153
162
|
// mốc cũ vào sẽ đẩy hội thoại đang sôi nổi xuống đáy danh sách chat.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phân biệt tin do BOT gửi với tin CHỦ MÁY tự gõ - cả hai đều đi ra từ cùng một tài khoản Zalo.
|
|
3
|
+
*
|
|
4
|
+
* Vì sao cần: bot Zalo cá nhân dùng chính tài khoản của khách. Khi zalo-connect nhặt tin về, tin
|
|
5
|
+
* bot viết và tin người gõ trên điện thoại giống hệt nhau (cùng sender_id, cùng from_self=1).
|
|
6
|
+
* Trước bản này, muốn biết "câu đó ai viết" phải mở log gateway soi xem có lượt gọi model quanh
|
|
7
|
+
* mốc đó không - làm được, nhưng chỉ người có SSH mới làm nổi, và log thì xoay vòng.
|
|
8
|
+
*
|
|
9
|
+
* Cách làm: OpenClaw phát `reply_payload_sending` ngay TRƯỚC khi gửi, kèm nguyên văn payload. Ghi
|
|
10
|
+
* vân tay nội dung đó vào sổ chờ; lát nữa tin quay về qua bridge thì đối chiếu. Khớp là 'bot',
|
|
11
|
+
* không khớp là 'human'.
|
|
12
|
+
*
|
|
13
|
+
* Vì sao so nội dung chứ không so messageId: payload lúc gửi CHƯA có id - id do Zalo cấp khi tin
|
|
14
|
+
* đã nằm trên server, và bridge trả về đường khác. Nội dung là thứ duy nhất hai đầu cùng thấy.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Mention đầu câu phải cắt vì mỗi chặng viết nó một kiểu:
|
|
19
|
+
* - payload OpenClaw gửi xuống: `Dạ anh Kent, …` hoặc `@[Kent] Dạ anh Kent, …`
|
|
20
|
+
* - tin quay về từ Zalo: `@Kent Dạ anh Kent, …` (zalo-connect gắn mention native,
|
|
21
|
+
* MỘT khoảng trắng - bản đầu đòi hai nên trượt sạch)
|
|
22
|
+
* Đo thật trên máy khách 13/09/2026: mọi câu trả lời trong nhóm đều bị gán nhầm "Tự gõ" vì chỗ này.
|
|
23
|
+
*
|
|
24
|
+
* Không đoán được tên dài mấy chữ, nên thay vì cắt cho "đúng", sinh NHIỀU biến thể rồi khớp biến
|
|
25
|
+
* thể nào cũng được: rẻ, và sai sót chỉ làm mất nhãn chứ không gán nhãn bừa.
|
|
26
|
+
*/
|
|
27
|
+
const MENTION_BRACKET = /^\s*@\[[^\]]{1,60}\]\s*/;
|
|
28
|
+
|
|
29
|
+
/** Bỏ `@Tên` đầu câu với `n` từ trong tên (1..5), dùng để sinh biến thể. */
|
|
30
|
+
function stripLeadingMentionWords(text, words) {
|
|
31
|
+
const re = new RegExp(`^\\s*@[^\\s]+(?:\\s+[^\\s]+){0,${words - 1}}\\s+`);
|
|
32
|
+
return text.replace(re, '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalize(text) {
|
|
36
|
+
return String(text ?? '')
|
|
37
|
+
.replace(/\r\n/g, '\n')
|
|
38
|
+
.replace(/\s+/g, ' ')
|
|
39
|
+
.trim()
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.slice(0, 400);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Vân tay chính: nguyên văn đã chuẩn hoá. */
|
|
45
|
+
export function fingerprint(text) {
|
|
46
|
+
return normalize(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mọi cách viết có thể có của CÙNG một câu, để hai đầu gặp nhau ở ít nhất một biến thể.
|
|
51
|
+
* Luôn gồm bản nguyên văn; nếu câu mở đầu bằng `@` thì thêm các bản đã cắt mention.
|
|
52
|
+
*/
|
|
53
|
+
export function fingerprintVariants(text) {
|
|
54
|
+
const base = String(text ?? '').replace(/\r\n/g, '\n').trim();
|
|
55
|
+
if (!base) return [];
|
|
56
|
+
const out = new Set();
|
|
57
|
+
const push = (t) => { const fp = normalize(t); if (fp) out.add(fp); };
|
|
58
|
+
push(base);
|
|
59
|
+
push(base.replace(MENTION_BRACKET, ''));
|
|
60
|
+
if (base.startsWith('@')) for (let w = 1; w <= 5; w += 1) push(stripLeadingMentionWords(base, w));
|
|
61
|
+
return [...out];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class SelfOriginTracker {
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} [opts]
|
|
67
|
+
* @param {number} [opts.ttlMs] giữ dấu bao lâu. Mặc định 10 phút: tin gửi đi thường quay về
|
|
68
|
+
* trong vài giây, nhưng lúc mạng chập hoặc zalo-connect nối lại thì có thể trễ vài phút.
|
|
69
|
+
* @param {number} [opts.max] chặn trên số dấu giữ cùng lúc, phòng rò bộ nhớ khi bridge im.
|
|
70
|
+
*/
|
|
71
|
+
constructor({ ttlMs = 10 * 60_000, max = 500, now = () => Date.now() } = {}) {
|
|
72
|
+
this.ttlMs = ttlMs;
|
|
73
|
+
this.max = max;
|
|
74
|
+
this.now = now;
|
|
75
|
+
/** @type {Map<string, object[]>} vân tay -> các dấu đang chờ mang vân tay đó */
|
|
76
|
+
this.pending = new Map();
|
|
77
|
+
/** @type {object[]} mọi dấu đang chờ, cũ trước mới sau (để dọn và chặn trên) */
|
|
78
|
+
this.entries = [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Ghi nhận "bot vừa gửi câu này". Gọi từ hook reply_payload_sending và từ adapter.
|
|
83
|
+
* Nhận thêm các bản viết khác của cùng câu (trước/sau khi gắn mention) - tất cả trỏ về MỘT dấu,
|
|
84
|
+
* nên khớp bản nào thì cả nhóm cùng bị tiêu, không để lại dấu thừa gán nhầm cho tin sau.
|
|
85
|
+
*/
|
|
86
|
+
remember(...texts) {
|
|
87
|
+
const fps = new Set();
|
|
88
|
+
for (const t of texts.flat()) for (const fp of fingerprintVariants(t)) fps.add(fp);
|
|
89
|
+
if (!fps.size) return false;
|
|
90
|
+
this.sweep();
|
|
91
|
+
const entry = { at: this.now(), fps: [...fps] };
|
|
92
|
+
for (const fp of entry.fps) {
|
|
93
|
+
const list = this.pending.get(fp) || [];
|
|
94
|
+
list.push(entry);
|
|
95
|
+
this.pending.set(fp, list);
|
|
96
|
+
}
|
|
97
|
+
this.entries.push(entry);
|
|
98
|
+
// Quá tải thì bỏ dấu CŨ NHẤT: nó ít khả năng còn đang trên đường nhất.
|
|
99
|
+
while (this.entries.length > this.max) this._drop(this.entries[0]);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Tin từ tài khoản này vừa quay về - của bot hay của người?
|
|
105
|
+
*
|
|
106
|
+
* Dấu khớp bị TIÊU khi dùng: gửi cùng một câu hai lần thì phải có hai dấu, nếu không lần thứ
|
|
107
|
+
* hai (người gõ lại y hệt) sẽ bị gán nhầm là bot.
|
|
108
|
+
*
|
|
109
|
+
* @returns {'bot'|'human'}
|
|
110
|
+
*/
|
|
111
|
+
claim(text) {
|
|
112
|
+
this.sweep();
|
|
113
|
+
for (const fp of fingerprintVariants(text)) {
|
|
114
|
+
const list = this.pending.get(fp);
|
|
115
|
+
if (!list?.length) continue;
|
|
116
|
+
this._drop(list[0]);
|
|
117
|
+
return 'bot';
|
|
118
|
+
}
|
|
119
|
+
return 'human';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Gỡ một dấu khỏi mọi biến thể của nó. */
|
|
123
|
+
_drop(entry) {
|
|
124
|
+
if (!entry) return;
|
|
125
|
+
for (const fp of entry.fps) {
|
|
126
|
+
const list = this.pending.get(fp);
|
|
127
|
+
if (!list) continue;
|
|
128
|
+
const i = list.indexOf(entry);
|
|
129
|
+
if (i >= 0) list.splice(i, 1);
|
|
130
|
+
if (!list.length) this.pending.delete(fp);
|
|
131
|
+
}
|
|
132
|
+
const i = this.entries.indexOf(entry);
|
|
133
|
+
if (i >= 0) this.entries.splice(i, 1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Dọn dấu quá hạn. */
|
|
137
|
+
sweep() {
|
|
138
|
+
const cutoff = this.now() - this.ttlMs;
|
|
139
|
+
for (const entry of [...this.entries]) if (entry.at < cutoff) this._drop(entry);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get size() {
|
|
143
|
+
return this.entries.length;
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/storage/database.js
CHANGED
|
@@ -46,8 +46,8 @@ export class SqliteStore {
|
|
|
46
46
|
this.db = db;
|
|
47
47
|
this.kind = 'sqlite';
|
|
48
48
|
this._insMsg = db.prepare(`INSERT OR REPLACE INTO messages
|
|
49
|
-
(id, conversation_id, sender_id, sender_name, text, raw_type, sent_at, quote_id, from_self, media_json)
|
|
50
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
49
|
+
(id, conversation_id, sender_id, sender_name, text, raw_type, sent_at, quote_id, from_self, media_json, origin)
|
|
50
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
51
51
|
this._insTurn = db.prepare(`INSERT OR REPLACE INTO turn_contexts
|
|
52
52
|
(id, message_id, sender_id, snapshot_json, status, created_at, expires_at)
|
|
53
53
|
VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
@@ -66,10 +66,12 @@ export class SqliteStore {
|
|
|
66
66
|
this._upsertConv.run(id, accountId, groupId ?? null, type, title ?? null, lastMessageAt ?? Date.now());
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
insertMessage({ id, conversationId, senderId, senderName, text, rawType, sentAt, quoteId, fromSelf, mediaUrls }) {
|
|
69
|
+
insertMessage({ id, conversationId, senderId, senderName, text, rawType, sentAt, quoteId, fromSelf, mediaUrls, origin }) {
|
|
70
70
|
this._insMsg.run(id, conversationId, senderId, senderName ?? '', text ?? '',
|
|
71
71
|
rawType ?? 'message', sentAt ?? Date.now(), quoteId ?? null,
|
|
72
|
-
fromSelf ? 1 : 0, mediaUrls?.length ? JSON.stringify(mediaUrls) : null
|
|
72
|
+
fromSelf ? 1 : 0, mediaUrls?.length ? JSON.stringify(mediaUrls) : null,
|
|
73
|
+
// Chỉ tin ĐI RA mới có nghĩa 'bot'/'human'; tin của người khác để NULL, đừng bịa nhãn.
|
|
74
|
+
fromSelf && (origin === 'bot' || origin === 'human') ? origin : null);
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
/**
|
|
@@ -281,6 +281,23 @@ export const MIGRATIONS = [
|
|
|
281
281
|
UPDATE tasks SET status = 'done' WHERE done_at IS NOT NULL AND status = 'todo';
|
|
282
282
|
`,
|
|
283
283
|
},
|
|
284
|
+
{
|
|
285
|
+
version: 10,
|
|
286
|
+
name: 'message-origin',
|
|
287
|
+
// Bot Zalo cá nhân gửi bằng CHÍNH tài khoản của khách, nên trong khung chat tin bot viết và
|
|
288
|
+
// tin chủ máy gõ tay nằm lẫn nhau: cùng sender_id, cùng sender_name, cùng from_self=1.
|
|
289
|
+
// Nhìn giao diện không tài nào phân biệt được - phải mở log gateway ra soi xem quanh mốc đó
|
|
290
|
+
// có cặp `[model-fetch] start/response` hay không (đã phải làm thật cho một khách ngày
|
|
291
|
+
// 12/09/2026 để trả lời đúng một câu hỏi: "câu này bot nhắn hay tôi nhắn?").
|
|
292
|
+
//
|
|
293
|
+
// `origin` ghi thẳng câu trả lời đó vào lúc ghi tin, nơi duy nhất còn biết chắc:
|
|
294
|
+
// 'bot' - nội dung khớp payload mà OpenClaw vừa gửi đi (reply_payload_sending)
|
|
295
|
+
// 'human' - tin từ tài khoản này nhưng không khớp payload nào => người gõ tay
|
|
296
|
+
// NULL - tin của người khác, hoặc tin cũ ghi trước bản này (không suy đoán ngược)
|
|
297
|
+
sql: `
|
|
298
|
+
ALTER TABLE messages ADD COLUMN origin TEXT;
|
|
299
|
+
`,
|
|
300
|
+
},
|
|
284
301
|
];
|
|
285
302
|
|
|
286
303
|
/**
|