openclaw-zalo-mod 2.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/README.vi.md +306 -0
- package/bvbank.jpg +0 -0
- package/dashboard.css +5954 -0
- package/dashboard.js +7392 -0
- package/index.html +1212 -0
- package/index.js +7558 -0
- package/logo.png +0 -0
- package/openclaw.plugin.json +96 -0
- package/package.json +85 -0
- package/skills/zalo-mod-control/SKILL.md +236 -0
- package/src/agent/commands.js +224 -0
- package/src/agent/connect-actions.js +160 -0
- package/src/agent/skill-content.js +327 -0
- package/src/agent/tool-surface.js +990 -0
- package/src/context/context-selector.js +72 -0
- package/src/context/conversation-buffer.js +120 -0
- package/src/context/prompt-injector.js +91 -0
- package/src/context/turn-context.js +142 -0
- package/src/crm/crm-api.js +121 -0
- package/src/crm/crm-store.js +1023 -0
- package/src/crm/task-reconcile.js +169 -0
- package/src/crm/zalo-people.js +191 -0
- package/src/integration/openclaw-adapter.js +235 -0
- package/src/integration/owner-claim.js +11 -0
- package/src/integration/zalo-connect-bridge.js +335 -0
- package/src/integration/zalo-mod-engine.js +379 -0
- package/src/integration/zca-facade.js +204 -0
- package/src/licensing/entitlements.js +96 -0
- package/src/messaging/conversation-queue.js +110 -0
- package/src/messaging/mention-builder.js +83 -0
- package/src/messaging/reply-mention-correlator.js +104 -0
- package/src/messaging/reply-router.js +182 -0
- package/src/storage/database.js +178 -0
- package/src/storage/migrations.js +314 -0
- package/upgrade/plans.json +80 -0
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent tool surface — cho LLM tay chân thật để điều khiển Zalo Mod.
|
|
3
|
+
*
|
|
4
|
+
* Bối cảnh: trước đây zalo-mod chỉ có 3 mặt tiền đổi state (slash command
|
|
5
|
+
* zero-token, dashboard HTTP, timer nội bộ) và LLM KHÔNG nằm trong số đó. Khi
|
|
6
|
+
* owner nhắn "mute nhóm A, nhóm B" bằng ngôn ngữ tự nhiên, tin đó lọt lên LLM,
|
|
7
|
+
* LLM không có tool nào nên trả lời nghe-hợp-lý ("đã mute rồi") mà không có gì
|
|
8
|
+
* ghi settings.json → badge dashboard đứng nguyên. File này là actuator còn
|
|
9
|
+
* thiếu đó.
|
|
10
|
+
*
|
|
11
|
+
* Mọi write đi qua ĐÚNG dispatcher mà nút dashboard bấm vào
|
|
12
|
+
* (`runDashboardAction`) nên licensing/audit/fan-out per-bot không thể lệch.
|
|
13
|
+
*
|
|
14
|
+
* BẢO MẬT — tool này bị kích hoạt bởi tin nhắn của BẤT KỲ ai trong nhóm:
|
|
15
|
+
* - Chỉ owner dùng được. `requesterSenderId` do host cấp từ inbound context
|
|
16
|
+
* (SDK ghi rõ "runtime-provided, not tool args") — KHÔNG BAO GIỜ nhận
|
|
17
|
+
* senderId từ params của model.
|
|
18
|
+
* - Chặn 2 lớp: factory không trả tool nào cho người không phải owner (tool
|
|
19
|
+
* biến mất khỏi prompt), và execute kiểm tra lại lần nữa với danh sách owner
|
|
20
|
+
* đọc live từ config.
|
|
21
|
+
* - Nhóm action tiền/license/permission bị chặn cứng; nhóm phá hoại
|
|
22
|
+
* (kick/block/leave) mặc định TẮT, bật bằng config.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Tên 4 tool — phải khớp `contracts.tools` trong openclaw.plugin.json. */
|
|
26
|
+
export const ZALO_MOD_TOOL_NAMES = Object.freeze([
|
|
27
|
+
'zalo_mod_groups',
|
|
28
|
+
'zalo_mod_settings',
|
|
29
|
+
'zalo_mod_history',
|
|
30
|
+
'zalo_mod_reports',
|
|
31
|
+
'zalo_mod_tasks',
|
|
32
|
+
'zalo_mod_action',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Action của `runDashboardAction` mà agent được phép gọi qua `zalo_mod_action`.
|
|
37
|
+
* Chỉ đọc + tác vụ vận hành thường ngày.
|
|
38
|
+
*/
|
|
39
|
+
export const AGENT_SAFE_ACTIONS = Object.freeze([
|
|
40
|
+
'get-group-info', 'get-user-info', 'get-friends', 'get-pending', 'get-blocked',
|
|
41
|
+
'get-permissions', 'get-name-triggers', 'group-detail', 'journal-data',
|
|
42
|
+
'scan-members', 'sync-groups', 'generate-summary', 'send-message',
|
|
43
|
+
'toggle-setting', 'bulk-toggle-setting',
|
|
44
|
+
// 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
|
+
// `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
|
+
// chỉ tới các đích đã cấu hình sẵn trong lịch, không nhận đích tuỳ ý từ agent.
|
|
47
|
+
// `report-job-save` sửa MỘT PHẦN (gửi {id, time} là đủ) và vẫn qua đúng kiểm tra hợp lệ như dashboard.
|
|
48
|
+
'report-jobs', 'report-digest-preview', 'report-job-run', 'report-job-save',
|
|
49
|
+
// `report-sent` chỉ đọc lịch sử đã gửi — để owner hỏi "sáng nay bot gửi gì" thì bot đọc được
|
|
50
|
+
// đúng bản đã gửi, thay vì dựng lại (dựng lại ra kết quả khác nếu lịch đã đổi từ lúc đó).
|
|
51
|
+
'report-sent',
|
|
52
|
+
// XOÁ lịch cũng cho, nhưng phanh là bước XÁC NHẬN HAI NHỊP trong `zalo_mod_reports`
|
|
53
|
+
// (operation="delete" không kèm confirm=true thì chỉ trả về sẽ xoá gì rồi bắt hỏi lại owner),
|
|
54
|
+
// KHÔNG phải cờ `allowDestructive`. Cờ đó mở kèm remove-user/block-member/leave-group —
|
|
55
|
+
// bắt owner mở cả chùm đó chỉ để xoá một lịch báo cáo là đổi phanh nhỏ lấy rủi ro lớn.
|
|
56
|
+
'report-job-delete',
|
|
57
|
+
// get-templates: bot đọc được KEY hợp lệ + nội dung hiện tại, nên "cập nhật welcome" mới làm được
|
|
58
|
+
// (trước đây chỉ có save-templates nên bot phải đoán key → báo không làm được).
|
|
59
|
+
'get-templates',
|
|
60
|
+
// zalo-api: một cửa sang ~141 action của zalo-connect. Tự có deny-by-default + phân loại
|
|
61
|
+
// read/write/destructive trong src/agent/connect-actions.js, và vẫn qua luật gói như mọi action.
|
|
62
|
+
'zalo-api',
|
|
63
|
+
'toggle-custom-mode', 'upsert-custom-mode', 'delete-custom-mode',
|
|
64
|
+
'set-name-triggers', 'save-templates',
|
|
65
|
+
// P15 (Kent chốt mở — đảo lại luật P4 "bot không có đường nào tới crm-task-*"): việc tồn đọng
|
|
66
|
+
// đọc/duyệt/từ chối/đổi cột được qua chat, đi ĐÚNG action mà dashboard dùng. TUYỆT ĐỐI KHÔNG mở
|
|
67
|
+
// `crm-task-delete` — xoá dữ liệu khách không bao giờ đi qua đường chat, kể cả với allowDestructive.
|
|
68
|
+
'crm-tasks-list', 'crm-tasks-board', 'crm-task-status', 'crm-task-approve',
|
|
69
|
+
'crm-task-approve-move', 'crm-task-reject',
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Action phá hoại / không thể hoàn tác. Mặc định TẮT với agent; bật bằng
|
|
74
|
+
* `agentTools.allowDestructive: true` trong plugin config.
|
|
75
|
+
*/
|
|
76
|
+
export const AGENT_DESTRUCTIVE_ACTIONS = Object.freeze([
|
|
77
|
+
'remove-user', 'block-member', 'unblock-member', 'leave-group',
|
|
78
|
+
'send-friend-request', 'accept-friend', 'reject-friend', 'review-pending',
|
|
79
|
+
'send-messages', 'bulk-friend-request',
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Không bao giờ cho agent chạm: tiền, license, và quyền truy cập. Những thứ này
|
|
84
|
+
* chỉ đổi được từ dashboard (token-gated) hoặc lệnh owner tường minh.
|
|
85
|
+
*/
|
|
86
|
+
export const AGENT_FORBIDDEN_ACTIONS = Object.freeze([
|
|
87
|
+
'create-payment', 'check-payment-status', 'cancel-payment',
|
|
88
|
+
'activate-license', 'refresh-license', 'save-permissions',
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
const MAX_HISTORY_MESSAGES = 400;
|
|
92
|
+
const MAX_HISTORY_GROUPS = 10;
|
|
93
|
+
const MAX_TEXT_LEN = 500;
|
|
94
|
+
|
|
95
|
+
/** Bỏ dấu + hạ chữ + gom khoảng trắng — so khớp tên nhóm người dùng gõ. */
|
|
96
|
+
export function foldGroupName(value) {
|
|
97
|
+
return String(value || '')
|
|
98
|
+
.normalize('NFD')
|
|
99
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
102
|
+
.trim();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Khớp tên nhóm theo TỪNG TỪ, không cần đúng thứ tự -- bỏ dấu qua `foldGroupName` trước (P3b).
|
|
107
|
+
*
|
|
108
|
+
* Sự cố thật 09/08: owner nhờ bật silent cho "39 Cùng rèn", so khớp CHUỖI CON không ra `[39] RÈN
|
|
109
|
+
* CÙNG NHAU` vì thứ tự chữ khác nhau ("cung ren" không phải chuỗi con của "ren cung nhau") -> bot kết
|
|
110
|
+
* luận sai "nhóm chưa được quản lý" dù nhóm đã bật follow/silent thật.
|
|
111
|
+
*
|
|
112
|
+
* Đòi khớp ĐỦ mọi từ vẫn quá cứng: người nhớ tên hay lẫn một từ (owner có thể gõ "39 - tự rèn" thay
|
|
113
|
+
* vì "cùng rèn"). Ngược lại khớp lỏng quá (chỉ cần 1 từ) thì một chữ số như "39" trùng ngẫu nhiên
|
|
114
|
+
* hàng chục nhóm. Chọn mốc giữa: **quá NỬA số từ phải khớp** (làm tròn lên) -- với truy vấn 1 từ thì
|
|
115
|
+
* vẫn đòi khớp tuyệt đối, không có chỗ cho sai.
|
|
116
|
+
*/
|
|
117
|
+
export function tokenMatchGroupName(name, query) {
|
|
118
|
+
const tokens = foldGroupName(query).split(' ').filter(Boolean);
|
|
119
|
+
if (!tokens.length) return false;
|
|
120
|
+
const foldedName = foldGroupName(name);
|
|
121
|
+
const matched = tokens.filter((t) => foldedName.includes(t)).length;
|
|
122
|
+
return matched >= Math.ceil(tokens.length / 2);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 5 nhóm "gần giống" nhất theo số từ trùng -- dùng khi khớp tên ra 0 kết quả, để owner còn cái chọn. */
|
|
126
|
+
export function suggestGroupNames(query, groups, limit = 5) {
|
|
127
|
+
const tokens = foldGroupName(query).split(' ').filter(Boolean);
|
|
128
|
+
return groups
|
|
129
|
+
.map((g) => ({ name: g.name, score: tokens.filter((t) => foldGroupName(g.name).includes(t)).length }))
|
|
130
|
+
.sort((a, b) => b.score - a.score)
|
|
131
|
+
.slice(0, limit)
|
|
132
|
+
.map((s) => s.name);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function toolText(payload, isError = false) {
|
|
136
|
+
const text = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
|
137
|
+
return { content: [{ type: 'text', text, ...(isError ? { isError: true } : {}) }], details: payload };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function ok(payload) { return toolText(payload); }
|
|
141
|
+
function fail(message, extra = {}) { return toolText({ ok: false, error: message, ...extra }, true); }
|
|
142
|
+
|
|
143
|
+
/** `due_at`/`updated_at` lưu epoch ms — đổi sang YYYY-MM-DD cho LLM đọc, null nếu không có. */
|
|
144
|
+
function isoDate(ms) {
|
|
145
|
+
if (!ms) return null;
|
|
146
|
+
try { return new Date(ms).toISOString().slice(0, 10); } catch { return null; }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Owner hợp lệ = ownerId gốc + ownerId của từng bot profile. */
|
|
150
|
+
export function collectOwnerIds(pluginCfg) {
|
|
151
|
+
const ids = new Set();
|
|
152
|
+
const push = (value) => { const id = String(value || '').trim(); if (id) ids.add(id); };
|
|
153
|
+
push(pluginCfg?.ownerId);
|
|
154
|
+
for (const bot of Object.values(pluginCfg?.bots || {})) push(bot?.ownerId);
|
|
155
|
+
return ids;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function isOwnerRequester(requesterSenderId, ownerIds) {
|
|
159
|
+
const id = String(requesterSenderId || '').trim();
|
|
160
|
+
if (!id) return false; // lượt không có sender (cron/heartbeat) → không phải owner
|
|
161
|
+
return ownerIds instanceof Set ? ownerIds.has(id) : new Set(ownerIds || []).has(id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Owner theo NGỮ CẢNH INBOUND, không chỉ theo id khớp bảng.
|
|
166
|
+
*
|
|
167
|
+
* Host cấp hai tín hiệu, và CẢ HAI chỉ được cấp khi gateway client có ADMIN_SCOPE (xem
|
|
168
|
+
* `canSupplyTrustedRequester` trong core) — nên `senderIsOwner` đáng tin y như `requesterSenderId`:
|
|
169
|
+
* requesterSenderId?: string — id người gửi
|
|
170
|
+
* senderIsOwner?: boolean — "trusted owner bit from inbound context"
|
|
171
|
+
*
|
|
172
|
+
* Trước đây chỉ so id, và đó là lỗi thật trên vps_asa (2026-07-31): trong DM thì id khớp `ownerId`
|
|
173
|
+
* đã cấu hình nên bot có tool; trong NHÓM host gửi `senderIsOwner: true` nhưng id không khớp bảng →
|
|
174
|
+
* plugin trả về 0 tool. Bot mất cả đường ĐỌC lẫn đường GHI nên nó tự ứng biến: lấy `cron` để đặt
|
|
175
|
+
* lịch (thành lịch ẩn), và trả lời trạng thái bằng ký ức hội thoại (nói 08:00 khi thật là 09:00).
|
|
176
|
+
* Mọi triệu chứng đêm đó đều từ một dòng gate này.
|
|
177
|
+
*/
|
|
178
|
+
export function isTrustedOwnerContext(toolContext, ownerIds) {
|
|
179
|
+
if (toolContext?.senderIsOwner === true) return true;
|
|
180
|
+
return isOwnerRequester(toolContext?.requesterSenderId, ownerIds);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Đổi danh sách người dùng gõ (tên nhóm / groupId / "all") thành groupId thật.
|
|
185
|
+
*
|
|
186
|
+
* Owner nói "mute nhóm Kinh Doanh" chứ không đọc groupId, nên bước này là bắt
|
|
187
|
+
* buộc. Khớp chính xác trước, rồi khớp chứa-chuỗi; nhập nhằng thì TRẢ VỀ để bot
|
|
188
|
+
* hỏi lại thay vì đoán.
|
|
189
|
+
*/
|
|
190
|
+
export function resolveGroupTargets(inputs, groups) {
|
|
191
|
+
const list = Array.isArray(inputs) ? inputs : [inputs];
|
|
192
|
+
const wantAll = list.some((v) => foldGroupName(v) === 'all' || foldGroupName(v) === 'tat ca');
|
|
193
|
+
if (wantAll) return { matched: groups.map((g) => g.groupId), unresolved: [], ambiguous: [] };
|
|
194
|
+
|
|
195
|
+
const matched = [];
|
|
196
|
+
const unresolved = [];
|
|
197
|
+
const ambiguous = [];
|
|
198
|
+
for (const raw of list) {
|
|
199
|
+
const needle = String(raw ?? '').trim();
|
|
200
|
+
if (!needle) continue;
|
|
201
|
+
const byId = groups.filter((g) => g.groupId === needle.replace(/^group:/, ''));
|
|
202
|
+
if (byId.length) { matched.push(...byId.map((g) => g.groupId)); continue; }
|
|
203
|
+
|
|
204
|
+
const folded = foldGroupName(needle);
|
|
205
|
+
if (!folded) { unresolved.push(needle); continue; }
|
|
206
|
+
const exact = groups.filter((g) => foldGroupName(g.name) === folded);
|
|
207
|
+
const pool = exact.length ? exact : groups.filter((g) => foldGroupName(g.name).includes(folded));
|
|
208
|
+
if (!pool.length) { unresolved.push(needle); continue; }
|
|
209
|
+
|
|
210
|
+
// Cùng một nhóm vật lý có nhiều groupId (mỗi bot một id per-account) —
|
|
211
|
+
// trùng tên là ĐÚNG, không phải nhập nhằng.
|
|
212
|
+
const distinctNames = new Set(pool.map((g) => foldGroupName(g.name)));
|
|
213
|
+
if (distinctNames.size > 1) {
|
|
214
|
+
ambiguous.push({ input: needle, candidates: [...new Set(pool.map((g) => g.name))].slice(0, 8) });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
matched.push(...pool.map((g) => g.groupId));
|
|
218
|
+
}
|
|
219
|
+
return { matched: [...new Set(matched)], unresolved, ambiguous };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Phân loại action cho `zalo_mod_action`. */
|
|
223
|
+
export function classifyAction(action, { allowDestructive = false } = {}) {
|
|
224
|
+
const name = String(action || '').trim();
|
|
225
|
+
if (!name) return { allowed: false, reason: 'Thiếu tên action.' };
|
|
226
|
+
if (AGENT_FORBIDDEN_ACTIONS.includes(name)) {
|
|
227
|
+
return { allowed: false, reason: `Action "${name}" liên quan thanh toán/license/quyền truy cập — chỉ đổi được từ dashboard, bot không được phép chạy.` };
|
|
228
|
+
}
|
|
229
|
+
if (AGENT_SAFE_ACTIONS.includes(name)) return { allowed: true, kind: 'safe' };
|
|
230
|
+
if (AGENT_DESTRUCTIVE_ACTIONS.includes(name)) {
|
|
231
|
+
return allowDestructive
|
|
232
|
+
? { allowed: true, kind: 'destructive' }
|
|
233
|
+
: { allowed: false, reason: `Action "${name}" có thể gây hậu quả không hoàn tác nên đang bị chặn. Owner bật bằng cách đặt agentTools.allowDestructive = true trong config zalo-mod.` };
|
|
234
|
+
}
|
|
235
|
+
return { allowed: false, reason: `Action "${name}" không nằm trong danh sách bot được phép gọi.` };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const SETTINGS_SCHEMA = {
|
|
239
|
+
type: 'object',
|
|
240
|
+
properties: {
|
|
241
|
+
groups: {
|
|
242
|
+
type: 'array',
|
|
243
|
+
items: { type: 'string' },
|
|
244
|
+
description: 'Tên nhóm (tiếng Việt có dấu cũng được), groupId, hoặc "all" cho mọi nhóm. Ví dụ: ["Kinh Doanh","Kỹ Thuật"].',
|
|
245
|
+
},
|
|
246
|
+
key: {
|
|
247
|
+
type: 'string',
|
|
248
|
+
enum: ['muted', 'silent', 'welcome', 'tracking', 'follow', 'pendingAuto', 'autoSummary', 'backlogInclude'],
|
|
249
|
+
description: 'Toggle cần đổi. muted = bot im lặng hoàn toàn; silent = chỉ reply khi @tag/gọi tên; follow = ghi lịch sử chat + memory; '
|
|
250
|
+
+ 'backlogInclude = nhóm này có vào tin "việc còn treo" (lịch backlog) hay không — tắt cho nhóm rèn luyện/nội bộ, '
|
|
251
|
+
+ 'không phải task team có deadline thật.',
|
|
252
|
+
},
|
|
253
|
+
value: { type: 'boolean', description: 'true = bật, false = tắt.' },
|
|
254
|
+
profile: { type: 'string', description: 'Chỉ áp cho một bot (accountId). Bỏ trống = áp cho mọi bot đang ở nhóm đó (khuyến nghị).' },
|
|
255
|
+
},
|
|
256
|
+
required: ['groups', 'key', 'value'],
|
|
257
|
+
additionalProperties: false,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const HISTORY_SCHEMA = {
|
|
261
|
+
type: 'object',
|
|
262
|
+
properties: {
|
|
263
|
+
groups: {
|
|
264
|
+
type: 'array',
|
|
265
|
+
items: { type: 'string' },
|
|
266
|
+
description: 'Tên nhóm hoặc groupId. Nhiều nhóm cùng lúc được.',
|
|
267
|
+
},
|
|
268
|
+
date: { type: 'string', description: 'Ngày YYYY-MM-DD theo giờ VN. Bỏ trống = hôm nay.' },
|
|
269
|
+
days: { type: 'integer', minimum: 1, maximum: 14, description: 'Lấy N ngày gần nhất tính từ `date` (mặc định 1).' },
|
|
270
|
+
summarize: { type: 'boolean', description: 'true = gọi bộ tổng hợp có sẵn của plugin (lưu lại bản tổng hợp, tốn 1 lượt LLM riêng). false/bỏ trống = trả transcript thô để bạn tự tổng hợp.' },
|
|
271
|
+
limit: { type: 'integer', minimum: 20, maximum: 400, description: `Số tin tối đa mỗi nhóm (mặc định ${MAX_HISTORY_MESSAGES}).` },
|
|
272
|
+
},
|
|
273
|
+
required: ['groups'],
|
|
274
|
+
additionalProperties: false,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/** "all"/"tất cả" → '*'; còn lại giữ nguyên tên nhóm để tầng dưới resolve. undefined = không đổi. */
|
|
278
|
+
function normalizeReportGroups(groups) {
|
|
279
|
+
if (groups === undefined) return undefined;
|
|
280
|
+
const list = (Array.isArray(groups) ? groups : [groups]).map((v) => String(v ?? '').trim()).filter(Boolean);
|
|
281
|
+
if (!list.length) return undefined;
|
|
282
|
+
if (list.some((v) => ['all', 'tat ca', 'tất cả'].includes(foldGroupName(v)) || v === '*')) return '*';
|
|
283
|
+
return list;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Lịch báo cáo — schema PHẲNG, cố ý không lồng.
|
|
288
|
+
*
|
|
289
|
+
* Bug thật, ba lần liên tiếp: owner nhờ "đổi lịch báo cáo tổng hợp thành 9h sáng vào nhóm ASACHINA
|
|
290
|
+
* ZALO", bot gọi vài action ĐỌC rồi báo "đã đổi xong" trong khi lịch không đổi. Đường duy nhất để ghi
|
|
291
|
+
* là `zalo_mod_action { action: "report-job-save", payload: { job: { … } } }` — model phải tự chọn đúng
|
|
292
|
+
* tên action trong danh sách hơn 40 cái RỒI lồng JSON ba lớp. Nó không làm nổi, kể cả khi tên action đã
|
|
293
|
+
* nằm trong mô tả tool.
|
|
294
|
+
*
|
|
295
|
+
* `zalo_mod_settings` thì luôn gọi đúng, vì phẳng + có enum + có required. Tool này bắt chước y hệt:
|
|
296
|
+
* mỗi thứ owner hay nhờ là MỘT field ở tầng ngoài cùng.
|
|
297
|
+
*/
|
|
298
|
+
const REPORTS_SCHEMA = {
|
|
299
|
+
type: 'object',
|
|
300
|
+
properties: {
|
|
301
|
+
operation: {
|
|
302
|
+
type: 'string',
|
|
303
|
+
enum: ['list', 'save', 'run', 'preview', 'delete'],
|
|
304
|
+
description: 'list = xem các lịch đang có (LÀM ĐẦU TIÊN để lấy id). save = tạo mới hoặc sửa. run = gửi ngay. preview = xem trước chuỗi sẽ gửi, không gửi. delete = xoá vĩnh viễn, cần confirm=true.',
|
|
305
|
+
},
|
|
306
|
+
confirm: {
|
|
307
|
+
type: 'boolean',
|
|
308
|
+
description: 'Chỉ dùng với operation="delete". Gọi lần đầu KHÔNG có confirm để biết sẽ xoá lịch nào, hỏi lại owner, rồi mới gọi lại với confirm=true.',
|
|
309
|
+
},
|
|
310
|
+
id: { type: 'string', description: 'id của lịch cần sửa (lấy từ operation="list"). Bỏ trống khi tạo lịch mới.' },
|
|
311
|
+
name: { type: 'string', description: 'Tên lịch, ví dụ "BC Tổng Hợp". Chỉ cần khi tạo mới.' },
|
|
312
|
+
time: { type: 'string', description: 'Giờ gửi mỗi ngày, dạng HH:MM giờ VN. Ví dụ "09:00".' },
|
|
313
|
+
reportFor: {
|
|
314
|
+
type: 'string',
|
|
315
|
+
enum: ['today', 'yesterday', 'last7', 'last30', 'thisMonth', 'custom'],
|
|
316
|
+
description: 'Báo cáo nói về khoảng thời gian nào. Lịch BUỔI SÁNG phải dùng "yesterday" — nếu để '
|
|
317
|
+
+ '"today" thì lúc 08:00 nó chỉ tóm tắt mấy tiếng đầu ngày (gần như trống) và cả ngày hôm trước '
|
|
318
|
+
+ 'không bao giờ được báo. Lịch cuối ngày (sau ~20:00) thì dùng "today". "last7"/"last30" = 7/30 '
|
|
319
|
+
+ 'ngày kết thúc HÔM QUA, "thisMonth" = từ đầu tháng tới hôm qua — owner nói "tổng hợp 7 ngày", '
|
|
320
|
+
+ '"báo cáo tháng này" thì dùng các giá trị này. "custom" cần thêm rangeFrom/rangeTo. '
|
|
321
|
+
+ '⚠️ "last7"/"last30"/"thisMonth"/"custom" CHỈ ĐỌC bản tổng hợp NGÀY đã có sẵn (summaries/), '
|
|
322
|
+
+ 'TUYỆT ĐỐI không sinh lại bằng AI — ngày nào chưa có bản tổng hợp thì báo cáo tự ghi rõ '
|
|
323
|
+
+ 'ngày đó thiếu, không phải lỗi, không phải bịa. Mặc định "today".',
|
|
324
|
+
},
|
|
325
|
+
rangeFrom: {
|
|
326
|
+
type: 'string',
|
|
327
|
+
description: 'Chỉ dùng khi reportFor="custom". Ngày bắt đầu, dạng YYYY-MM-DD.',
|
|
328
|
+
},
|
|
329
|
+
rangeTo: {
|
|
330
|
+
type: 'string',
|
|
331
|
+
description: 'Chỉ dùng khi reportFor="custom". Ngày kết thúc, dạng YYYY-MM-DD.',
|
|
332
|
+
},
|
|
333
|
+
kind: {
|
|
334
|
+
type: 'string',
|
|
335
|
+
enum: ['digest', 'group', 'backlog'],
|
|
336
|
+
description: 'digest = gộp tất cả nhóm vào MỘT tin ngắn. group = mỗi nhóm một tin đầy đủ. '
|
|
337
|
+
+ 'backlog = "việc còn treo" của các nhóm — TRUY VẤN kanban đã có, KHÔNG dùng AI để sinh lại. '
|
|
338
|
+
+ 'Dùng "backlog" khi owner nói kiểu "báo cáo còn việc gì chưa làm", "việc tồn đọng", "nhắc việc chưa xong".',
|
|
339
|
+
},
|
|
340
|
+
groups: {
|
|
341
|
+
type: 'array',
|
|
342
|
+
items: { type: 'string' },
|
|
343
|
+
description: 'Các nhóm được tổng hợp. Tên nhóm có dấu cũng được, hoặc ["all"] cho tất cả nhóm đang follow.',
|
|
344
|
+
},
|
|
345
|
+
toOwnerDm: { type: 'boolean', description: 'true = gửi vào DM riêng của owner.' },
|
|
346
|
+
toGroups: {
|
|
347
|
+
type: 'array',
|
|
348
|
+
items: { type: 'string' },
|
|
349
|
+
description: 'Gửi báo cáo vào (các) nhóm này — tên nhóm cũng được. Dùng khi owner nói "gửi vào nhóm X".',
|
|
350
|
+
},
|
|
351
|
+
toEachGroup: { type: 'boolean', description: 'true = mỗi nhóm tự nhận báo cáo của nó. Chỉ dùng với kind="group".' },
|
|
352
|
+
enabled: { type: 'boolean', description: 'false = tạm tắt lịch mà không xoá.' },
|
|
353
|
+
},
|
|
354
|
+
required: ['operation'],
|
|
355
|
+
additionalProperties: false,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* P15: việc tồn đọng (kanban "Công việc") điều khiển bằng lời. Schema PHẲNG như `zalo_mod_settings`/
|
|
360
|
+
* `zalo_mod_reports` — owner nói tên việc, không nói id, nên `title` phải khớp gần đúng thay vì đòi
|
|
361
|
+
* chính xác. Đảo lại luật P4 ("bot không có đường nào tới crm-task-*") theo quyết định của Kent —
|
|
362
|
+
* xem CHANGELOG mục P15 để biết đây là đổi có chủ ý, không phải lỗ hổng.
|
|
363
|
+
*/
|
|
364
|
+
const TASKS_SCHEMA = {
|
|
365
|
+
type: 'object',
|
|
366
|
+
properties: {
|
|
367
|
+
operation: {
|
|
368
|
+
type: 'string',
|
|
369
|
+
enum: ['list', 'status', 'approve', 'reject'],
|
|
370
|
+
description: 'list = xem việc còn treo, lọc được theo nhóm + trạng thái. status = đổi cột '
|
|
371
|
+
+ '(todo/doing/blocked/done) của một việc ĐÃ duyệt — không nhận "pending_review", muốn duyệt '
|
|
372
|
+
+ 'dùng operation="approve". approve = duyệt việc AI đề xuất (bỏ trạng thái chờ xác nhận), có '
|
|
373
|
+
+ 'thể kèm `status` để duyệt và chuyển cột trong một lượt. reject = từ chối việc AI đề xuất — '
|
|
374
|
+
+ 'KHÔNG xoá dữ liệu, chỉ ẩn khỏi kanban/báo cáo, có thể tự quay lại "Chờ xác nhận" sau 30 ngày.',
|
|
375
|
+
},
|
|
376
|
+
groups: {
|
|
377
|
+
type: 'array',
|
|
378
|
+
items: { type: 'string' },
|
|
379
|
+
description: 'Tên nhóm (có dấu cũng được) hoặc groupId, hoặc ["all"]. operation="list": lọc phạm '
|
|
380
|
+
+ 'vi xem. Các operation khác: dùng để thu hẹp khi `title` khớp trùng nhiều nhóm. Bỏ trống = mọi nhóm.',
|
|
381
|
+
},
|
|
382
|
+
status: {
|
|
383
|
+
type: 'string',
|
|
384
|
+
enum: ['pending_review', 'todo', 'doing', 'blocked', 'done'],
|
|
385
|
+
description: 'operation="list": lọc theo cột kanban, bỏ trống = mọi việc CHƯA xong (không gồm '
|
|
386
|
+
+ '"done"). operation="status": cột đích, chỉ nhận todo|doing|blocked|done. operation="approve": '
|
|
387
|
+
+ 'tuỳ chọn, có thì duyệt và chuyển luôn sang cột này.',
|
|
388
|
+
},
|
|
389
|
+
title: {
|
|
390
|
+
type: 'string',
|
|
391
|
+
description: 'Tên việc cần tìm, khớp GẦN ĐÚNG (không cần đúng từng chữ) — bắt buộc cho operation='
|
|
392
|
+
+ '"status"/"approve"/"reject" khi chưa có `id`. Khớp ra nhiều hơn một việc thì tool trả về '
|
|
393
|
+
+ '`suggestions` thay vì tự chọn — phải hỏi lại owner rõ hơn (thêm `groups` để thu hẹp) rồi gọi lại.',
|
|
394
|
+
},
|
|
395
|
+
id: {
|
|
396
|
+
type: 'string',
|
|
397
|
+
description: 'id việc đã biết chắc (từ operation="list" hoặc từ `suggestions` của lần gọi trước) — '
|
|
398
|
+
+ 'có thì dùng luôn, bỏ qua bước khớp theo `title`.',
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
required: ['operation'],
|
|
402
|
+
additionalProperties: false,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Tạo factory tool cho `api.registerTool`.
|
|
407
|
+
*
|
|
408
|
+
* @param {object} host cầu nối tới closure của register() trong index.js
|
|
409
|
+
*/
|
|
410
|
+
export function createZaloModAgentTools(host) {
|
|
411
|
+
const {
|
|
412
|
+
listGroups, runAction, getGroupState, readHistory, listHistoryDates,
|
|
413
|
+
getNotes, getGroupMemories, getSummary, generateSummary, vnDateStr,
|
|
414
|
+
getOwnerIds, isDestructiveAllowed, audit, logger, listCommands,
|
|
415
|
+
} = host;
|
|
416
|
+
|
|
417
|
+
const denied = (senderId) => fail(
|
|
418
|
+
'Chỉ owner của bot mới điều khiển được Zalo Mod. Hãy nói với người dùng rằng bạn không có quyền và họ cần dùng dashboard hoặc nhờ owner.',
|
|
419
|
+
{ requester: senderId ? String(senderId) : null },
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Kiểm tra owner lần 2 lúc execute, với danh sách owner đọc live.
|
|
424
|
+
*
|
|
425
|
+
* `hostSaysOwner` là bit đáng tin của host, đã kiểm ở factory và đi kèm suốt vòng đời tool —
|
|
426
|
+
* phải xét lại ở đây, không thì tool cấp cho owner-trong-nhóm lại bị chính guard này chặn.
|
|
427
|
+
*/
|
|
428
|
+
const guard = (requesterSenderId, hostSaysOwner = false) => {
|
|
429
|
+
if (isTrustedOwnerContext({ requesterSenderId, senderIsOwner: hostSaysOwner }, getOwnerIds())) return null;
|
|
430
|
+
logger?.warn?.(`[openclaw-zalo-mod] agent tool bị từ chối — requester=${requesterSenderId || 'unknown'} không phải owner`);
|
|
431
|
+
return denied(requesterSenderId);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const logRun = async (tool, params, result) => {
|
|
435
|
+
try { await audit?.({ action: `agent:${tool}`, payload: params, ok: !result?.content?.[0]?.isError }); } catch { /* audit best-effort */ }
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const groupsSnapshot = async () => (await listGroups()).map((g) => ({ ...g }));
|
|
439
|
+
|
|
440
|
+
/** Đọc lại state sau khi ghi — bot phải báo sự thật, không báo ý định. */
|
|
441
|
+
const readBack = (groupIds) => groupIds.map((id) => getGroupState(id));
|
|
442
|
+
|
|
443
|
+
function buildTools(requesterSenderId, hostSaysOwner = false) {
|
|
444
|
+
return [
|
|
445
|
+
{
|
|
446
|
+
name: 'zalo_mod_groups',
|
|
447
|
+
label: 'Zalo Mod — danh sách nhóm & trạng thái',
|
|
448
|
+
description: [
|
|
449
|
+
'Liệt kê mọi nhóm Zalo mà bot đang quản lý kèm TRẠNG THÁI THẬT của từng toggle',
|
|
450
|
+
'(muted/silent/welcome/follow/autoSummary) — đúng những badge hiển thị trên dashboard Zalo Mod.',
|
|
451
|
+
'Gọi tool này TRƯỚC khi trả lời bất kỳ câu hỏi về cấu hình nhóm, và SAU mỗi lần đổi cấu hình để xác nhận.',
|
|
452
|
+
'query khớp THEO TỪNG TỪ, không dấu, không cần đúng thứ tự — "39 cùng rèn" vẫn ra đúng nhóm dù',
|
|
453
|
+
'tên thật là "[39] RÈN CÙNG NHAU". Nếu trả về "total: 0" kèm "suggestions": tin có nhóm gần giống,',
|
|
454
|
+
// Bài học 09/08: khớp chuỗi-con thất bại vì thứ tự chữ khác nhau → bot tự kết luận "nhóm chưa
|
|
455
|
+
// được quản lý" dù nhóm ĐÃ follow/silent thật, nghe y như bịa. Luật phải nằm ở mô tả tool (nơi
|
|
456
|
+
// LUÔN nằm trong prompt) để thắng được hướng dẫn "phải đi tìm" của SKILL.md.
|
|
457
|
+
'⛔ TUYỆT ĐỐI đừng tự kết luận "nhóm chưa được Zalo Mod quản lý" chỉ vì không khớp đúng tên owner',
|
|
458
|
+
'gõ — hỏi lại owner tên chính xác hơn, hoặc gợi ý một trong các suggestions.',
|
|
459
|
+
'Chỉ owner dùng được.',
|
|
460
|
+
].join(' '),
|
|
461
|
+
parameters: {
|
|
462
|
+
type: 'object',
|
|
463
|
+
properties: {
|
|
464
|
+
query: { type: 'string', description: 'Lọc theo tên nhóm (không dấu cũng được, không cần đúng thứ tự từ). Bỏ trống = tất cả.' },
|
|
465
|
+
includeCommands: { type: 'boolean', description: 'true = kèm danh sách slash command đầy đủ của từng bot.' },
|
|
466
|
+
},
|
|
467
|
+
additionalProperties: false,
|
|
468
|
+
},
|
|
469
|
+
execute: async (_toolCallId, params = {}) => {
|
|
470
|
+
const blocked = guard(requesterSenderId, hostSaysOwner);
|
|
471
|
+
if (blocked) return blocked;
|
|
472
|
+
const all = await groupsSnapshot();
|
|
473
|
+
const folded = foldGroupName(params.query);
|
|
474
|
+
let groups = folded ? all.filter((g) => foldGroupName(g.name).includes(folded)) : all;
|
|
475
|
+
// Khớp chuỗi-con thất bại (thứ tự từ khác nhau, sự cố "39 Cùng rèn" 09/08) → thử lại
|
|
476
|
+
// bằng TOKEN trước khi kết luận "không có nhóm nào khớp".
|
|
477
|
+
if (folded && !groups.length) {
|
|
478
|
+
groups = all.filter((g) => tokenMatchGroupName(g.name, params.query));
|
|
479
|
+
}
|
|
480
|
+
const zeroResult = !!folded && !groups.length;
|
|
481
|
+
const result = ok({
|
|
482
|
+
ok: true,
|
|
483
|
+
total: all.length,
|
|
484
|
+
groups,
|
|
485
|
+
...(params.includeCommands ? { commands: listCommands() } : {}),
|
|
486
|
+
...(zeroResult
|
|
487
|
+
? {
|
|
488
|
+
suggestions: suggestGroupNames(params.query, all),
|
|
489
|
+
note: 'KHÔNG tìm thấy nhóm theo tên này. ĐỪNG kết luận nhóm chưa được quản lý — hỏi lại owner hoặc chọn trong suggestions.',
|
|
490
|
+
}
|
|
491
|
+
: { note: 'Đây là state thật đọc từ store của plugin. Báo lại đúng các giá trị này, không suy diễn.' }),
|
|
492
|
+
});
|
|
493
|
+
await logRun('zalo_mod_groups', params, result);
|
|
494
|
+
return result;
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
name: 'zalo_mod_settings',
|
|
499
|
+
label: 'Zalo Mod — bật/tắt tính năng theo nhóm',
|
|
500
|
+
description: [
|
|
501
|
+
'Bật/tắt một toggle của Zalo Mod cho một hoặc nhiều nhóm — TƯƠNG ĐƯƠNG bấm badge trên dashboard.',
|
|
502
|
+
'Dùng tool này khi owner nói kiểu "mute nhóm A và nhóm B", "bật follow hết", "tắt welcome nhóm X".',
|
|
503
|
+
'Nhận TÊN nhóm, không cần groupId. Tool tự đồng bộ runtime policy và trả về state đã đọc lại.',
|
|
504
|
+
'TUYỆT ĐỐI không tự nhận đã đổi xong khi chưa gọi tool này và chưa thấy applied > 0.',
|
|
505
|
+
].join(' '),
|
|
506
|
+
parameters: SETTINGS_SCHEMA,
|
|
507
|
+
execute: async (_toolCallId, params = {}) => {
|
|
508
|
+
const blocked = guard(requesterSenderId, hostSaysOwner);
|
|
509
|
+
if (blocked) return blocked;
|
|
510
|
+
const all = await groupsSnapshot();
|
|
511
|
+
const { matched, unresolved, ambiguous } = resolveGroupTargets(params.groups, all);
|
|
512
|
+
if (ambiguous.length) {
|
|
513
|
+
return fail('Tên nhóm chưa rõ — hỏi lại owner chọn nhóm nào trước khi đổi cấu hình.', { ambiguous, unresolved });
|
|
514
|
+
}
|
|
515
|
+
if (!matched.length) {
|
|
516
|
+
return fail('Không tìm thấy nhóm nào khớp. Gọi zalo_mod_groups để xem tên nhóm đúng rồi hỏi lại owner.', { unresolved, known: all.map((g) => g.name) });
|
|
517
|
+
}
|
|
518
|
+
// Chọn action ĐÚNG NHƯ dashboard chọn, vì licensing gắn vào tên
|
|
519
|
+
// action: mọi action `bulk-*` đòi gói PRO kể cả khi chỉ 1 nhóm.
|
|
520
|
+
// Một nhóm Zalo có thể có nhiều groupId (mỗi bot một id) — đó vẫn
|
|
521
|
+
// là MỘT nhóm về mặt logic nên phải đi đường single, không thì
|
|
522
|
+
// owner gói FREE bấm badge được mà nhờ bot lại báo "cần PRO".
|
|
523
|
+
const logicalGroups = new Set(matched.map((id) => {
|
|
524
|
+
const info = all.find((g) => g.groupId === id);
|
|
525
|
+
return foldGroupName(info?.name) || id;
|
|
526
|
+
}));
|
|
527
|
+
const single = logicalGroups.size <= 1;
|
|
528
|
+
const profilePart = params.profile ? { profile: String(params.profile) } : {};
|
|
529
|
+
const payload = single
|
|
530
|
+
? { groupId: matched[0], key: params.key, value: !!params.value, ...profilePart }
|
|
531
|
+
: { groupIds: matched, key: params.key, value: !!params.value, ...profilePart };
|
|
532
|
+
try {
|
|
533
|
+
const raw = await runAction(single ? 'toggle-setting' : 'bulk-toggle-setting', payload);
|
|
534
|
+
const result = ok({
|
|
535
|
+
ok: true,
|
|
536
|
+
key: params.key,
|
|
537
|
+
value: !!params.value,
|
|
538
|
+
applied: raw?.count ?? raw?.applied ?? matched.length,
|
|
539
|
+
runtimePolicy: raw?.runtimePolicy,
|
|
540
|
+
groups: readBack(matched),
|
|
541
|
+
unresolved,
|
|
542
|
+
note: 'Đã ghi và đọc lại. Báo cho owner đúng theo mảng groups ở trên.',
|
|
543
|
+
});
|
|
544
|
+
await logRun('zalo_mod_settings', params, result);
|
|
545
|
+
return result;
|
|
546
|
+
} catch (e) {
|
|
547
|
+
const result = fail(`Không đổi được cấu hình: ${e.message}`, {
|
|
548
|
+
code: e.code || null,
|
|
549
|
+
hint: e.code === 'PRO_REQUIRED' || e.code === 'TEAM_REQUIRED'
|
|
550
|
+
? 'Đây là giới hạn gói license, không phải lỗi kỹ thuật. Nói thẳng cho owner biết cần nâng gói. Cách làm được ngay với gói FREE: gọi lại tool này NHIỀU LƯỢT, mỗi lượt đúng MỘT nhóm.'
|
|
551
|
+
: undefined,
|
|
552
|
+
attemptedGroups: readBack(matched),
|
|
553
|
+
});
|
|
554
|
+
await logRun('zalo_mod_settings', params, result);
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
name: 'zalo_mod_history',
|
|
561
|
+
label: 'Zalo Mod — đọc lịch sử chat nhóm',
|
|
562
|
+
description: [
|
|
563
|
+
'Đọc lịch sử chat đã ghi của các nhóm đang bật follow/tracking, kèm ghi chú admin và memory của ngày đó.',
|
|
564
|
+
'Dùng khi owner nhờ "tổng hợp nhóm A, nhóm B", "hôm nay nhóm X nói gì", "tóm tắt tuần này".',
|
|
565
|
+
'Mặc định trả transcript thô để bạn tự tổng hợp bằng lời của mình.',
|
|
566
|
+
'Đặt summarize=true nếu owner muốn LƯU bản tổng hợp chuẩn của plugin (xuất hiện trong Nhật ký nhóm trên dashboard).',
|
|
567
|
+
].join(' '),
|
|
568
|
+
parameters: HISTORY_SCHEMA,
|
|
569
|
+
execute: async (_toolCallId, params = {}) => {
|
|
570
|
+
const blocked = guard(requesterSenderId, hostSaysOwner);
|
|
571
|
+
if (blocked) return blocked;
|
|
572
|
+
const all = await groupsSnapshot();
|
|
573
|
+
const { matched, unresolved, ambiguous } = resolveGroupTargets(params.groups, all);
|
|
574
|
+
if (ambiguous.length) return fail('Tên nhóm chưa rõ — hỏi lại owner.', { ambiguous });
|
|
575
|
+
if (!matched.length) {
|
|
576
|
+
return fail('Không tìm thấy nhóm nào khớp.', { unresolved, known: all.map((g) => g.name) });
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Cùng một nhóm vật lý có thể có nhiều groupId (một id / bot).
|
|
580
|
+
// Lịch sử ghi theo id nào có tin thì lấy id đó — gộp theo tên.
|
|
581
|
+
const byName = new Map();
|
|
582
|
+
for (const id of matched) {
|
|
583
|
+
const info = all.find((g) => g.groupId === id);
|
|
584
|
+
const key = foldGroupName(info?.name) || id;
|
|
585
|
+
if (!byName.has(key)) byName.set(key, []);
|
|
586
|
+
byName.get(key).push(id);
|
|
587
|
+
}
|
|
588
|
+
if (byName.size > MAX_HISTORY_GROUPS) {
|
|
589
|
+
return fail(`Quá nhiều nhóm một lượt (${byName.size}). Chia nhỏ tối đa ${MAX_HISTORY_GROUPS} nhóm.`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const baseDate = String(params.date || vnDateStr());
|
|
593
|
+
const days = Math.max(1, Math.min(14, Number(params.days) || 1));
|
|
594
|
+
const dates = [];
|
|
595
|
+
for (let i = 0; i < days; i += 1) {
|
|
596
|
+
const d = new Date(`${baseDate}T00:00:00Z`);
|
|
597
|
+
d.setUTCDate(d.getUTCDate() - i);
|
|
598
|
+
dates.push(d.toISOString().slice(0, 10));
|
|
599
|
+
}
|
|
600
|
+
const limit = Math.max(20, Math.min(MAX_HISTORY_MESSAGES, Number(params.limit) || MAX_HISTORY_MESSAGES));
|
|
601
|
+
|
|
602
|
+
const out = [];
|
|
603
|
+
for (const [, ids] of byName) {
|
|
604
|
+
const info = all.find((g) => ids.includes(g.groupId)) || { groupId: ids[0], name: ids[0] };
|
|
605
|
+
const entry = {
|
|
606
|
+
group: info.name,
|
|
607
|
+
groupIds: ids,
|
|
608
|
+
followEnabled: ids.some((id) => getGroupState(id)?.follow),
|
|
609
|
+
days: [],
|
|
610
|
+
};
|
|
611
|
+
if (!entry.followEnabled) {
|
|
612
|
+
entry.warning = 'Nhóm này chưa bật follow nên plugin không ghi lịch sử. Đề nghị owner bật follow trước (dùng zalo_mod_settings key=follow value=true) — chỉ có dữ liệu từ lúc bật trở đi.';
|
|
613
|
+
}
|
|
614
|
+
for (const date of dates) {
|
|
615
|
+
let messages = [];
|
|
616
|
+
for (const id of ids) {
|
|
617
|
+
const rows = await readHistory(id, date);
|
|
618
|
+
if (rows?.length) messages = rows;
|
|
619
|
+
if (messages.length) break;
|
|
620
|
+
}
|
|
621
|
+
const activeId = ids.find((id) => messages.length) || ids[0];
|
|
622
|
+
const trimmed = messages.slice(-limit).map((m) => ({
|
|
623
|
+
t: m.t,
|
|
624
|
+
name: m.name || m.userId || '',
|
|
625
|
+
text: String(m.text || '').slice(0, MAX_TEXT_LEN),
|
|
626
|
+
...(m.links?.length ? { links: m.links } : {}),
|
|
627
|
+
}));
|
|
628
|
+
const day = {
|
|
629
|
+
date,
|
|
630
|
+
messageCount: messages.length,
|
|
631
|
+
truncated: messages.length > trimmed.length,
|
|
632
|
+
messages: trimmed,
|
|
633
|
+
notes: (await getNotes(activeId)).filter((n) => vnDateStr(new Date(n.ts)) === date).map((n) => ({ name: n.userName, text: n.text })),
|
|
634
|
+
memories: (await getGroupMemories(activeId)).filter((m) => vnDateStr(new Date(m.ts)) === date).map((m) => ({ name: m.userName, text: m.text })),
|
|
635
|
+
};
|
|
636
|
+
if (params.summarize) {
|
|
637
|
+
try { day.summary = await generateSummary(activeId, date); }
|
|
638
|
+
catch (e) { day.summaryError = e.message; }
|
|
639
|
+
} else {
|
|
640
|
+
const existing = await getSummary(activeId, date);
|
|
641
|
+
if (existing) day.existingSummary = existing;
|
|
642
|
+
}
|
|
643
|
+
entry.days.push(day);
|
|
644
|
+
}
|
|
645
|
+
entry.availableDates = (await listHistoryDates(ids[0])).slice(0, 30);
|
|
646
|
+
out.push(entry);
|
|
647
|
+
}
|
|
648
|
+
const result = ok({
|
|
649
|
+
ok: true,
|
|
650
|
+
dates,
|
|
651
|
+
groups: out,
|
|
652
|
+
unresolved,
|
|
653
|
+
note: params.summarize
|
|
654
|
+
? 'Bản tổng hợp đã được lưu vào Nhật ký nhóm.'
|
|
655
|
+
: 'Transcript thô. Tự tổng hợp bằng lời của bạn, ngắn gọn theo quy tắc group. Nếu messageCount = 0 thì nói thật là không có dữ liệu, đừng bịa.',
|
|
656
|
+
});
|
|
657
|
+
await logRun('zalo_mod_history', params, result);
|
|
658
|
+
return result;
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
name: 'zalo_mod_reports',
|
|
663
|
+
label: 'Zalo Mod — lịch báo cáo tổng hợp',
|
|
664
|
+
description: [
|
|
665
|
+
'Xem / tạo / sửa / xoá lịch báo cáo lịch sử chat — TƯƠNG ĐƯƠNG trang "Nhật ký → Lịch báo cáo" trên dashboard.',
|
|
666
|
+
'Dùng tool này khi owner nói kiểu "đổi lịch báo cáo tổng hợp thành 9h sáng", "gửi báo cáo vào nhóm X",',
|
|
667
|
+
'"tạo lịch tổng hợp tất cả nhóm", "tắt lịch báo cáo", "xoá lịch báo cáo", "gửi thử báo cáo cho tôi xem".',
|
|
668
|
+
// Bẫy đã xảy ra thật (vps_asa 2026-07-31): AGENTS.md của bot dạy "Cron khi cần giờ chính
|
|
669
|
+
// xác, kết quả gửi thẳng vào channel" — khớp từng chữ với "đổi lịch báo cáo thành 8h, gửi
|
|
670
|
+
// vào nhóm X". Bot tạo cron job, báo đã xong, còn dashboard vẫn hiện giờ cũ → owner tưởng
|
|
671
|
+
// bot nói dối. Hướng dẫn luôn-bật thắng SKILL.md phải-đi-tìm, nên luật phải nằm Ở ĐÂY.
|
|
672
|
+
'⛔ ĐÂY LÀ ĐƯỜNG DUY NHẤT để đặt/đổi lịch báo cáo. TUYỆT ĐỐI không dùng tool `cron` cho việc này,',
|
|
673
|
+
'kể cả khi owner nói giờ chính xác và nói gửi vào nhóm nào — cron tạo ra lịch ẩn mà dashboard',
|
|
674
|
+
'không hiện, owner xem thấy giờ cũ và tưởng bạn báo sai. Cron chỉ dành cho việc hẹn giờ KHÁC.',
|
|
675
|
+
'Nhận TÊN nhóm, không cần groupId. Luôn gọi operation="list" trước để lấy id của lịch cần sửa.',
|
|
676
|
+
'Sửa một phần là đủ: đổi giờ chỉ cần { operation:"save", id, time }.',
|
|
677
|
+
'TUYỆT ĐỐI không tự nhận đã đổi xong khi chưa gọi operation="save" và chưa thấy ok trong kết quả.',
|
|
678
|
+
// Bẫy thật (vps_asa 01:23 ngày 2026-07-31): owner nhờ đổi giờ lần thứ hai, bot thấy trong
|
|
679
|
+
// lịch sử hội thoại là lượt trước mình đã báo "đã đổi xong 08:00" nên trả lời "lịch hiện
|
|
680
|
+
// đã đúng" mà KHÔNG gọi tool nào. Thực tế lịch đang 09:00 và đang tắt. Lịch sử hội thoại
|
|
681
|
+
// không phải trạng thái — dashboard, owner, hay agent khác đều có thể đã đổi từ lúc đó.
|
|
682
|
+
'⛔ Owner hỏi hay nhờ BẤT CỨ GÌ về lịch báo cáo thì PHẢI gọi operation="list" rồi mới trả lời —',
|
|
683
|
+
'kể cả khi bạn nhớ là lượt trước đã làm rồi, kể cả khi định nói "lịch hiện đã đúng".',
|
|
684
|
+
'Lời bạn nói ở lượt trước KHÔNG phải bằng chứng về trạng thái hiện tại. Chỉ kết quả tool mới là.',
|
|
685
|
+
].join(' '),
|
|
686
|
+
parameters: REPORTS_SCHEMA,
|
|
687
|
+
execute: async (_toolCallId, params = {}) => {
|
|
688
|
+
const blocked = guard(requesterSenderId, hostSaysOwner);
|
|
689
|
+
if (blocked) return blocked;
|
|
690
|
+
const op = String(params.operation || '').trim();
|
|
691
|
+
try {
|
|
692
|
+
if (op === 'list') {
|
|
693
|
+
const result = await runAction('report-jobs', {});
|
|
694
|
+
await logRun('zalo_mod_reports', params, result);
|
|
695
|
+
return ok({ ok: true, ...result });
|
|
696
|
+
}
|
|
697
|
+
if (op === 'preview') {
|
|
698
|
+
const groups = normalizeReportGroups(params.groups);
|
|
699
|
+
const previewPayload = { groups: groups ?? '*' };
|
|
700
|
+
if (params.reportFor !== undefined) previewPayload.reportFor = params.reportFor;
|
|
701
|
+
if (params.rangeFrom !== undefined) previewPayload.rangeFrom = params.rangeFrom;
|
|
702
|
+
if (params.rangeTo !== undefined) previewPayload.rangeTo = params.rangeTo;
|
|
703
|
+
const result = await runAction('report-digest-preview', previewPayload);
|
|
704
|
+
await logRun('zalo_mod_reports', params, result);
|
|
705
|
+
return ok({ ok: true, ...result });
|
|
706
|
+
}
|
|
707
|
+
if (op === 'run') {
|
|
708
|
+
if (!params.id) return fail('Thiếu id. Gọi operation="list" trước để lấy id của lịch.');
|
|
709
|
+
const result = await runAction('report-job-run', { id: params.id });
|
|
710
|
+
await logRun('zalo_mod_reports', params, result);
|
|
711
|
+
return ok({ ok: true, ...result });
|
|
712
|
+
}
|
|
713
|
+
if (op === 'delete') {
|
|
714
|
+
const id = String(params.id || '').trim();
|
|
715
|
+
if (!id) return fail('Thiếu id. Gọi operation="list" trước để lấy id của lịch cần xoá.');
|
|
716
|
+
const before = await runAction('report-jobs', {});
|
|
717
|
+
const target = (before?.jobs || []).find((j) => j.id === id);
|
|
718
|
+
if (!target) return fail(`Không có lịch nào id "${id}". Gọi operation="list" để xem lại.`);
|
|
719
|
+
// Xoá lịch là không hoàn tác được và owner phải cài lại từ đầu, nên chặn một nhịp:
|
|
720
|
+
// lần gọi đầu chỉ trả về SẼ xoá cái gì, buộc bot hỏi lại owner rồi mới xoá thật.
|
|
721
|
+
if (params.confirm !== true) {
|
|
722
|
+
return ok({
|
|
723
|
+
ok: false,
|
|
724
|
+
needsConfirm: true,
|
|
725
|
+
willDelete: { id: target.id, name: target.name, time: target.time, kind: target.kind },
|
|
726
|
+
note: `Chưa xoá gì. Hỏi lại owner có chắc xoá lịch "${target.name}" (${target.time}) không, `
|
|
727
|
+
+ 'rồi gọi lại operation="delete" kèm confirm=true. Không tự quyết.',
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
const result = await runAction('report-job-delete', { id });
|
|
731
|
+
const after = await runAction('report-jobs', {});
|
|
732
|
+
const out = { ok: true, deleted: { id: target.id, name: target.name }, jobs: after?.jobs || [], ...result };
|
|
733
|
+
await logRun('zalo_mod_reports', params, out);
|
|
734
|
+
return ok(out);
|
|
735
|
+
}
|
|
736
|
+
if (op !== 'save') return fail(`operation "${op}" không hợp lệ. Dùng: list | save | run | preview | delete.`);
|
|
737
|
+
|
|
738
|
+
// Dựng payload lồng cho runDashboardAction TỪ các field phẳng — chỗ model hay sai
|
|
739
|
+
// nhất, nên để code làm thay vì bắt model tự lồng JSON.
|
|
740
|
+
const job = { id: String(params.id || '').trim() || `job-${Date.now().toString(36)}` };
|
|
741
|
+
if (params.name !== undefined) job.name = params.name;
|
|
742
|
+
if (params.time !== undefined) job.time = params.time;
|
|
743
|
+
if (params.reportFor !== undefined) job.reportFor = params.reportFor;
|
|
744
|
+
if (params.rangeFrom !== undefined) job.rangeFrom = params.rangeFrom;
|
|
745
|
+
if (params.rangeTo !== undefined) job.rangeTo = params.rangeTo;
|
|
746
|
+
if (params.kind !== undefined) job.kind = params.kind;
|
|
747
|
+
if (params.enabled !== undefined) job.enabled = params.enabled;
|
|
748
|
+
const groups = normalizeReportGroups(params.groups);
|
|
749
|
+
if (groups !== undefined) job.groups = groups;
|
|
750
|
+
const deliver = {};
|
|
751
|
+
if (params.toOwnerDm !== undefined) deliver.ownerDm = params.toOwnerDm;
|
|
752
|
+
if (params.toEachGroup !== undefined) deliver.eachGroup = params.toEachGroup;
|
|
753
|
+
if (params.toGroups !== undefined) deliver.groups = Array.isArray(params.toGroups) ? params.toGroups : [params.toGroups];
|
|
754
|
+
if (Object.keys(deliver).length) job.deliver = deliver;
|
|
755
|
+
|
|
756
|
+
const saved = await runAction('report-job-save', { job });
|
|
757
|
+
// Đọc lại NGAY và trả về state thật, để model không phải tự tin vào lời mình.
|
|
758
|
+
const after = await runAction('report-jobs', {});
|
|
759
|
+
const result = { ok: true, saved: saved?.job || saved, jobs: after?.jobs || [] };
|
|
760
|
+
await logRun('zalo_mod_reports', params, result);
|
|
761
|
+
return ok(result);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
await logRun('zalo_mod_reports', params, { ok: false, error: err.message });
|
|
764
|
+
return fail(err.message);
|
|
765
|
+
}
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
name: 'zalo_mod_tasks',
|
|
770
|
+
label: 'Zalo Mod — việc tồn đọng (kanban)',
|
|
771
|
+
description: [
|
|
772
|
+
'Xem / duyệt / từ chối / đổi cột việc tồn đọng — TƯƠNG ĐƯƠNG trang "Công việc" (kanban) trên dashboard.',
|
|
773
|
+
'Dùng khi owner hỏi "nhóm X còn việc gì chưa xong", hoặc nói một việc cụ thể đã xong/đã duyệt/sai/nhiễu.',
|
|
774
|
+
// P15 (Kent chốt mở, đảo lại luật P4 "bot không có đường nào tới crm-task-*"): trước đây
|
|
775
|
+
// cố tình không cho bot chạm việc AI đề xuất, để tránh bot tự duyệt lén qua chat. Giờ owner
|
|
776
|
+
// muốn nói bằng lời cũng duyệt được — an toàn vẫn giữ vì owner PHẢI tự nói tên việc + nói rõ
|
|
777
|
+
// ý muốn (duyệt/từ chối/đổi cột), không phải bot tự quyết.
|
|
778
|
+
'Owner nói TÊN việc, không nói id — luôn dùng `title` để tool tự khớp gần đúng, ĐỪNG tự bịa ra',
|
|
779
|
+
'một id. Khớp ra NHIỀU HƠN MỘT việc thì tool trả `suggestions` — hỏi lại owner rõ hơn (kèm tên',
|
|
780
|
+
'nhóm vào `groups`) rồi gọi lại, TUYỆT ĐỐI không tự chọn đại một cái. Khớp ra 0 việc thì nói thật',
|
|
781
|
+
'là không tìm thấy, đừng suy ra là "đã xong" hay "đã có".',
|
|
782
|
+
'operation="status" chỉ đổi cột của việc ĐÃ duyệt (todo/doing/blocked/done) — việc còn ở',
|
|
783
|
+
'"Chờ xác nhận" (🤖 AI đề xuất) phải dùng operation="approve" (kèm `status` nếu muốn duyệt và',
|
|
784
|
+
'chuyển cột luôn trong một lượt).',
|
|
785
|
+
'"Từ chối" (reject) KHÔNG xoá dữ liệu, chỉ ẩn khỏi kanban/báo cáo — việc có thể tự quay lại',
|
|
786
|
+
'"Chờ xác nhận" sau 30 ngày nếu AI vẫn thấy owner nhắc lại, đó không phải lỗi.',
|
|
787
|
+
'TUYỆT ĐỐI không có thao tác xoá việc qua tool này — xoá dữ liệu khách chỉ làm được trên dashboard.',
|
|
788
|
+
].join(' '),
|
|
789
|
+
parameters: TASKS_SCHEMA,
|
|
790
|
+
execute: async (_toolCallId, params = {}) => {
|
|
791
|
+
const blocked = guard(requesterSenderId, hostSaysOwner);
|
|
792
|
+
if (blocked) return blocked;
|
|
793
|
+
const op = String(params.operation || '').trim();
|
|
794
|
+
// P15: audit phải ghi RÕ ai đứng sau một lượt qua chat, không chỉ 'ai' chung chung —
|
|
795
|
+
// sau này còn lần được vết "ai đã bảo bot đóng việc này".
|
|
796
|
+
const actor = `agent:${requesterSenderId || 'unknown'}`;
|
|
797
|
+
try {
|
|
798
|
+
const all = await groupsSnapshot();
|
|
799
|
+
const groupNameOf = (gid) => all.find((g) => g.groupId === gid)?.name || gid;
|
|
800
|
+
|
|
801
|
+
let scopedGroupIds = null;
|
|
802
|
+
if (params.groups !== undefined) {
|
|
803
|
+
const { matched, unresolved, ambiguous } = resolveGroupTargets(params.groups, all);
|
|
804
|
+
if (ambiguous.length) return fail('Tên nhóm chưa rõ — hỏi lại owner chọn nhóm nào.', { ambiguous, unresolved });
|
|
805
|
+
if (!matched.length) return fail('Không tìm thấy nhóm nào khớp. Gọi zalo_mod_groups để xem tên nhóm đúng.', { unresolved });
|
|
806
|
+
scopedGroupIds = new Set(matched);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Nguồn dữ liệu DUY NHẤT: đúng action dashboard dùng cho kanban, không tự query CRM
|
|
810
|
+
// riêng — không thể lệch với những gì owner đang thấy trên màn hình.
|
|
811
|
+
const board = await runAction('crm-tasks-board', {}, actor);
|
|
812
|
+
const flat = [];
|
|
813
|
+
for (const [col, arr] of Object.entries(board?.columns || {})) {
|
|
814
|
+
for (const task of (arr || [])) flat.push({ ...task, _col: col });
|
|
815
|
+
}
|
|
816
|
+
const inScope = scopedGroupIds ? flat.filter((t) => scopedGroupIds.has(t.group_id)) : flat;
|
|
817
|
+
const toView = (t) => ({
|
|
818
|
+
id: t.id, title: t.title, status: t._col, groupId: t.group_id,
|
|
819
|
+
groupName: groupNameOf(t.group_id), assignee: t.assignee || null,
|
|
820
|
+
note: t.note || null, dueAt: isoDate(t.due_at), source: t.source,
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
if (op === 'list') {
|
|
824
|
+
const wantStatus = params.status;
|
|
825
|
+
const rows = inScope.filter((t) => (wantStatus ? t._col === wantStatus : t._col !== 'done'));
|
|
826
|
+
const result = ok({
|
|
827
|
+
ok: true, total: rows.length, tasks: rows.map(toView),
|
|
828
|
+
note: '`status` là CỘT kanban thật (pending_review/todo/doing/blocked/done) — báo đúng giá trị này, đừng suy diễn.',
|
|
829
|
+
});
|
|
830
|
+
await logRun('zalo_mod_tasks', params, result);
|
|
831
|
+
return result;
|
|
832
|
+
}
|
|
833
|
+
if (!['status', 'approve', 'reject'].includes(op)) {
|
|
834
|
+
return fail(`operation "${op}" không hợp lệ. Dùng: list | status | approve | reject.`);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Xác định đúng MỘT việc: theo id nếu owner/model đã biết chắc, không thì khớp title.
|
|
838
|
+
let target = null;
|
|
839
|
+
if (params.id) {
|
|
840
|
+
target = flat.find((t) => t.id === params.id);
|
|
841
|
+
if (!target) return fail(`Không có việc nào id "${params.id}". Gọi operation="list" để xem lại.`);
|
|
842
|
+
} else {
|
|
843
|
+
const title = String(params.title || '').trim();
|
|
844
|
+
if (!title) return fail('Thiếu `title` (hoặc `id`) — cần biết ĐÚNG việc nào trước khi đổi.');
|
|
845
|
+
const folded = foldGroupName(title);
|
|
846
|
+
const exact = inScope.filter((t) => foldGroupName(t.title) === folded);
|
|
847
|
+
// Dùng lại đúng bộ khớp theo TỪNG TỪ của P3b (`tokenMatchGroupName`) — hàm chỉ so
|
|
848
|
+
// chuỗi thuần, không riêng cho tên nhóm, nên áp được luôn cho tên việc.
|
|
849
|
+
const pool = exact.length ? exact : inScope.filter((t) => tokenMatchGroupName(t.title, title));
|
|
850
|
+
if (!pool.length) {
|
|
851
|
+
return fail(`Không tìm thấy việc nào khớp "${title}". Gọi operation="list" để xem tên đúng — đừng suy ra là đã xong.`, {
|
|
852
|
+
known: inScope.slice(0, 20).map((t) => t.title),
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
if (pool.length > 1) {
|
|
856
|
+
return fail(`Khớp "${title}" ra ${pool.length} việc — chưa rõ là việc nào.`, {
|
|
857
|
+
suggestions: pool.map(toView),
|
|
858
|
+
note: 'Hỏi lại owner chọn đúng việc nào (có thể kèm tên nhóm vào `groups` để thu hẹp), hoặc gọi lại kèm `id` lấy từ đây. KHÔNG được tự chọn.',
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
[target] = pool;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (op === 'status') {
|
|
865
|
+
const status = String(params.status || '').trim();
|
|
866
|
+
if (!['todo', 'doing', 'blocked', 'done'].includes(status)) {
|
|
867
|
+
return fail('operation="status" cần `status` là todo|doing|blocked|done. Muốn duyệt từ "Chờ xác nhận" thì dùng operation="approve".');
|
|
868
|
+
}
|
|
869
|
+
const raw = await runAction('crm-task-status', { id: target.id, status }, actor);
|
|
870
|
+
const result = ok({ ok: true, task: raw });
|
|
871
|
+
await logRun('zalo_mod_tasks', params, result);
|
|
872
|
+
return result;
|
|
873
|
+
}
|
|
874
|
+
if (op === 'approve') {
|
|
875
|
+
const status = params.status !== undefined ? String(params.status).trim() : undefined;
|
|
876
|
+
if (status !== undefined && !['todo', 'doing', 'blocked', 'done'].includes(status)) {
|
|
877
|
+
return fail('`status` kèm operation="approve" chỉ nhận todo|doing|blocked|done.');
|
|
878
|
+
}
|
|
879
|
+
const raw = status
|
|
880
|
+
? await runAction('crm-task-approve-move', { id: target.id, status }, actor)
|
|
881
|
+
: await runAction('crm-task-approve', { id: target.id }, actor);
|
|
882
|
+
const result = ok({ ok: true, task: raw });
|
|
883
|
+
await logRun('zalo_mod_tasks', params, result);
|
|
884
|
+
return result;
|
|
885
|
+
}
|
|
886
|
+
// reject
|
|
887
|
+
const raw = await runAction('crm-task-reject', { id: target.id }, actor);
|
|
888
|
+
const result = ok({
|
|
889
|
+
ok: true, task: raw,
|
|
890
|
+
note: 'Đã ẩn khỏi kanban/báo cáo, KHÔNG xoá dữ liệu — có thể tự quay lại "Chờ xác nhận" sau 30 ngày nếu AI thấy lại việc này.',
|
|
891
|
+
});
|
|
892
|
+
await logRun('zalo_mod_tasks', params, result);
|
|
893
|
+
return result;
|
|
894
|
+
} catch (err) {
|
|
895
|
+
const result = fail(err.message);
|
|
896
|
+
await logRun('zalo_mod_tasks', params, result);
|
|
897
|
+
return result;
|
|
898
|
+
}
|
|
899
|
+
},
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
name: 'zalo_mod_action',
|
|
903
|
+
label: 'Zalo Mod — chạy action của dashboard',
|
|
904
|
+
// Mô tả tool LUÔN nằm trong prompt, còn SKILL.md là progressive-disclosure (model phải chủ
|
|
905
|
+
// động mở mới đọc). Bug thật: owner nhờ đổi giờ lịch báo cáo hai lần, model không mở skill
|
|
906
|
+
// nên không biết `report-job-save` tồn tại, chỉ gọi mấy action ĐỌC rồi báo "đã đổi xong"
|
|
907
|
+
// trong khi lịch không đổi. Vì vậy tên action ghi + hình dạng payload của những việc hay
|
|
908
|
+
// được nhờ phải nằm NGAY ĐÂY, không để trong skill.
|
|
909
|
+
description: [
|
|
910
|
+
'Chạy đúng những action mà mỗi nút trên dashboard Zalo Mod gọi.',
|
|
911
|
+
'Gọi action="list-actions" để xem danh sách đầy đủ được phép.',
|
|
912
|
+
'',
|
|
913
|
+
'ĐỌC: report-jobs (danh sách lịch báo cáo + danh sách nhóm), journal-data, get-templates,',
|
|
914
|
+
'get-group-info, get-permissions, generate-summary.',
|
|
915
|
+
'',
|
|
916
|
+
'GHI — dùng ĐÚNG action này, đừng truyền field cấu hình vào action đọc (sẽ bị từ chối):',
|
|
917
|
+
'• Lịch báo cáo: report-job-save, payload { job: { id, time, kind, groups, deliver } } —',
|
|
918
|
+
' chỉ cần id + field muốn đổi. VD đổi giờ: { job: { id: "job-x", time: "09:00" } }.',
|
|
919
|
+
' Nơi nhận: deliver { ownerDm, eachGroup, groups: ["Tên nhóm"] }. kind: "digest" | "group".',
|
|
920
|
+
' groups: "*" (tất cả nhóm follow) hoặc mảng tên nhóm/groupId.',
|
|
921
|
+
'• Template (nội quy/welcome/menu…): save-templates, payload { key, content }; đọc key hợp lệ',
|
|
922
|
+
' bằng get-templates.',
|
|
923
|
+
'• Thao tác Zalo khác (đổi tên nhóm, ảnh nhóm, phó nhóm, poll…): zalo-api,',
|
|
924
|
+
' payload { action: "rename-group", params: { … } }.',
|
|
925
|
+
'',
|
|
926
|
+
'BẮT BUỘC: chỉ được nói với owner là đã thay đổi SAU KHI action GHI trả về ok. Nếu chưa gọi',
|
|
927
|
+
'action ghi, hoặc nó trả lỗi, thì phải nói thẳng là chưa làm được kèm lý do — tuyệt đối không',
|
|
928
|
+
'suy ra thành công từ một action đọc.',
|
|
929
|
+
'',
|
|
930
|
+
'Action liên quan tiền/license/quyền bị chặn cứng; action phá hoại cần owner bật riêng trong config.',
|
|
931
|
+
].join(' '),
|
|
932
|
+
parameters: {
|
|
933
|
+
type: 'object',
|
|
934
|
+
properties: {
|
|
935
|
+
action: { type: 'string', description: 'Tên action, hoặc "list-actions" để xem danh sách được phép.' },
|
|
936
|
+
payload: { type: 'object', description: 'Payload của action (giống body dashboard gửi).', additionalProperties: true },
|
|
937
|
+
},
|
|
938
|
+
required: ['action'],
|
|
939
|
+
additionalProperties: false,
|
|
940
|
+
},
|
|
941
|
+
execute: async (_toolCallId, params = {}) => {
|
|
942
|
+
const blocked = guard(requesterSenderId, hostSaysOwner);
|
|
943
|
+
if (blocked) return blocked;
|
|
944
|
+
const action = String(params.action || '').trim();
|
|
945
|
+
if (action === 'list-actions') {
|
|
946
|
+
return ok({
|
|
947
|
+
ok: true,
|
|
948
|
+
safe: AGENT_SAFE_ACTIONS,
|
|
949
|
+
destructive: AGENT_DESTRUCTIVE_ACTIONS,
|
|
950
|
+
destructiveEnabled: !!isDestructiveAllowed(),
|
|
951
|
+
forbidden: AGENT_FORBIDDEN_ACTIONS,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
const verdict = classifyAction(action, { allowDestructive: !!isDestructiveAllowed() });
|
|
955
|
+
if (!verdict.allowed) return fail(verdict.reason);
|
|
956
|
+
try {
|
|
957
|
+
const raw = await runAction(action, params.payload || {});
|
|
958
|
+
const result = ok({ ok: true, action, kind: verdict.kind, result: raw });
|
|
959
|
+
await logRun('zalo_mod_action', params, result);
|
|
960
|
+
return result;
|
|
961
|
+
} catch (e) {
|
|
962
|
+
const result = fail(`Action "${action}" lỗi: ${e.message}`, { code: e.code || null });
|
|
963
|
+
await logRun('zalo_mod_action', params, result);
|
|
964
|
+
return result;
|
|
965
|
+
}
|
|
966
|
+
},
|
|
967
|
+
},
|
|
968
|
+
];
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Factory cho api.registerTool. Host gọi lại mỗi lượt agent với ctx của lượt
|
|
973
|
+
* đó (descriptor cache có khoá theo requesterSenderId nên an toàn).
|
|
974
|
+
*/
|
|
975
|
+
return function zaloModToolFactory(toolContext) {
|
|
976
|
+
const requesterSenderId = toolContext?.requesterSenderId;
|
|
977
|
+
if (!isTrustedOwnerContext(toolContext, getOwnerIds())) {
|
|
978
|
+
// KHÔNG được im lặng. Trả [] mà không log là thứ đã tốn nhiều giờ chẩn đoán: bot mất tool,
|
|
979
|
+
// rồi tự ứng biến (dùng cron, trả lời bằng ký ức), còn log thì trống trơn nên trông y như
|
|
980
|
+
// model bịa. Một dòng warn là đủ để lần sau grep ra ngay.
|
|
981
|
+
logger?.warn?.('[openclaw-zalo-mod] KHÔNG cấp agent tool: người gửi không phải owner'
|
|
982
|
+
+ ` — requesterSenderId=${requesterSenderId || '(host không cấp)'}`
|
|
983
|
+
+ ` senderIsOwner=${toolContext?.senderIsOwner === undefined ? '(host không cấp)' : toolContext.senderIsOwner}`
|
|
984
|
+
+ ` ownerId đã cấu hình=[${[...getOwnerIds()].join(', ') || '(chưa set)'}]`
|
|
985
|
+
+ ` sessionKey=${toolContext?.sessionKey || '-'}`);
|
|
986
|
+
return [];
|
|
987
|
+
}
|
|
988
|
+
return buildTools(requesterSenderId, toolContext?.senderIsOwner === true);
|
|
989
|
+
};
|
|
990
|
+
}
|