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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
  [![OpenClaw Plugin](https://img.shields.io/badge/OpenClaw-Plugin-blue.svg)](https://openclaw.ai)
7
- [![Version](https://img.shields.io/badge/version-2.31.1-green.svg)](./CHANGELOG.md)
7
+ [![Version](https://img.shields.io/badge/version-2.32.2-green.svg)](./CHANGELOG.md)
8
8
 
9
9
  **[🇺🇸 English](./README.md)**
10
10
 
package/README.vi.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
  [![OpenClaw Plugin](https://img.shields.io/badge/OpenClaw-Plugin-blue.svg)](https://openclaw.ai)
7
- [![Version](https://img.shields.io/badge/version-2.31.1-green.svg)](./CHANGELOG.md)
7
+ [![Version](https://img.shields.io/badge/version-2.32.2-green.svg)](./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.31.1';
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.31.1</span></strong>
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.agents?.list?.[0]?.id;
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?.agents?.list || [];
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
- return parseProfiles(profileStr)[0] || 'default';
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?.agents?.list?.[0]?.workspace;
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?.agents?.list || [])) {
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 = 'default';
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'}`);
@@ -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.31.1",
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, gửi lời mời kết bạn) khi owner ra lệnh. Mặc định tắt."
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-zalo-mod",
3
- "version": "2.31.1",
3
+ "version": "2.32.2",
4
4
  "description": "Zero-token Zalo group moderation plugin for OpenClaw — slash commands, anti-spam, warn system, and memory integration.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -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
- 'send-friend-request', 'accept-friend', 'reject-friend', 'review-pending',
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
- return isOwnerRequester(toolContext?.requesterSenderId, ownerIds);
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
- return buildTools(requesterSenderId, toolContext?.senderIsOwner === true);
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
- const svc = getService();
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 svc.subscribeHistory(cb);
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 svc.subscribeTyping(cb);
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
+ }
@@ -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
  /**