openclaw-zalo-mod 2.31.2 → 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.2-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.2-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.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.31.2</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.
@@ -1088,7 +1112,10 @@ const plugin = definePluginEntry({
1088
1112
  return String(profileStr ?? 'default').split(',').map(s => s.trim()).filter(Boolean);
1089
1113
  }
1090
1114
  function primaryProfile(profileStr) {
1091
- 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();
1092
1119
  }
1093
1120
  function mergeProfileStr(existing, prof) {
1094
1121
  // KHÔNG dùng parseProfiles(existing) vì nó fallback undefined→'default',
@@ -4966,7 +4993,7 @@ Quy tắc:
4966
4993
 
4967
4994
  // Determine the profile — group nhiều bot lưu CSV "default,mkt", phải lấy
4968
4995
  // primary (bot đầu) làm profile hợp lệ để gọi withZaloApi, không dùng nguyên chuỗi.
4969
- let targetProfile = 'default';
4996
+ let targetProfile = resolveDefaultProfile();
4970
4997
  if (payload.profile) {
4971
4998
  targetProfile = payload.profile;
4972
4999
  } else if (groupId && groupNames[groupId]?.profile) {
@@ -6101,6 +6128,9 @@ Quy tắc:
6101
6128
  text: m.text,
6102
6129
  sentAt: Number(m.sent_at) || 0,
6103
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,
6104
6134
  media: m.media_json ? JSON.parse(m.media_json) : undefined,
6105
6135
  })),
6106
6136
  };
@@ -6784,7 +6814,16 @@ Quy tắc:
6784
6814
 
6785
6815
  api.on('reply_payload_sending', (event, ctx) => {
6786
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 đó.
6787
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 { }
6788
6827
  if (!result) return;
6789
6828
  if (result.changed) {
6790
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.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, 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.2",
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
  /**