lalo-cli 0.2.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 ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Diginno
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # LALO CLI
2
+
3
+ Lệnh máy để đọc chat Zalo đã vào LALO, ghi hồ sơ khách, để bình luận nội bộ, và soạn/gửi tin có bước duyệt. **Không** đăng nhập Zalo trên máy bạn — dùng nick đang chạy trên LALO + khoá có phạm vi.
4
+
5
+ Cài:
6
+
7
+ ```bash
8
+ npm install -g lalo-cli
9
+ lalo --help
10
+ ```
11
+
12
+ Lệnh trên máy là `lalo` (còn alias `zalocrm-cli` trong phiên bản này). Tên gói npm là `lalo-cli` vì tổ chức `@lalo` trên npm đã có chủ khác.
13
+
14
+ ## Cấu hình 5 phút
15
+
16
+ ```bash
17
+ lalo config set server https://chat.diginno.net
18
+ lalo config set api-key <KEY>
19
+ lalo auth status
20
+ ```
21
+
22
+ Khoá tạo trong LALO › Cài đặt › Khoá API, loại CLI, giới hạn nick và quyền. `auth status` hiện tổ chức, nick, quyền, hạn dùng — **không** in lại khoá thô.
23
+
24
+ Biến môi trường (CI): `LALO_BASE_URL`, `LALO_API_KEY`. Tên cũ `ZALOCRM_CLI_*` vẫn nhận, có cảnh báo.
25
+
26
+ Lệnh cũ `zalocrm-cli` vẫn chạy trong phiên bản này.
27
+
28
+ ## Lệnh chính
29
+
30
+ - `lalo conv list|scan|sweep` — hội thoại / tin nhắn
31
+ - `lalo comment list|add` — bình luận nội bộ (không gửi Zalo)
32
+ - `lalo contact facts|fact-add` — sự thật bền trên hồ sơ khách
33
+ - `lalo tag list|show|apply` — tag CRM
34
+ - `lalo msg draft|send|send-file` — nháp / gửi có duyệt
35
+
36
+ Gửi tin mặc định **chỉ xem trước**. Có `--confirm` mới gửi thật.
@@ -0,0 +1,108 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename, extname } from 'node:path';
3
+ import { loadConfig, CliConfigError } from './config.js';
4
+ export { CliConfigError };
5
+ const MIME_BY_EXT = {
6
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif',
7
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm',
8
+ '.pdf': 'application/pdf',
9
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
10
+ '.xls': 'application/vnd.ms-excel',
11
+ '.csv': 'text/csv',
12
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
13
+ '.doc': 'application/msword',
14
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
15
+ '.ppt': 'application/vnd.ms-powerpoint',
16
+ '.zip': 'application/zip',
17
+ '.gz': 'application/gzip',
18
+ '.rar': 'application/vnd.rar',
19
+ '.tar': 'application/x-tar',
20
+ };
21
+ /** Đọc cấu hình: cờ lệnh → LALO_* → ZALOCRM_CLI_* (cảnh báo) → file ~/.config/lalo. */
22
+ export function loadConfigFromEnv() {
23
+ return loadConfig();
24
+ }
25
+ /** Chốt chặn chạy loạn cho `--all`: 50 trang × limit tối đa 200 = 10.000 bản ghi. */
26
+ const MAX_AUTO_PAGES = 50;
27
+ /**
28
+ * Lật hết trang bằng offset cho tới khi `hasMore=false`.
29
+ * `order='prepend'` dành cho danh sách trả về mới→cũ nhưng in ra cũ→mới (tin nhắn):
30
+ * trang sau chứa dữ liệu cũ hơn nên phải ghép lên đầu.
31
+ */
32
+ export async function fetchAllPages(fetchPage, getItems, merge, order = 'append') {
33
+ let offset = 0;
34
+ let page = 0;
35
+ let acc = [];
36
+ let last;
37
+ for (;;) {
38
+ last = await fetchPage(offset);
39
+ const items = getItems(last);
40
+ acc = order === 'prepend' ? [...items, ...acc] : [...acc, ...items];
41
+ offset += items.length;
42
+ page += 1;
43
+ // items rỗng mà vẫn hasMore ⇒ dừng, nếu không sẽ lặp vô hạn tại chỗ.
44
+ if (!last.hasMore || items.length === 0)
45
+ break;
46
+ if (page >= MAX_AUTO_PAGES) {
47
+ process.stderr.write(`⚠ --all dừng ở ${MAX_AUTO_PAGES} trang (${acc.length} bản ghi) để không chạy loạn. `
48
+ + `Còn dữ liệu chưa lấy — thu hẹp bộ lọc, hoặc chạy tiếp với --offset ${offset}.\n`);
49
+ break;
50
+ }
51
+ }
52
+ return merge(last, acc);
53
+ }
54
+ export async function cliGet(config, path, query = {}) {
55
+ const url = new URL(config.baseUrl + path);
56
+ for (const [key, value] of Object.entries(query)) {
57
+ if (value !== undefined && value !== '')
58
+ url.searchParams.set(key, String(value));
59
+ }
60
+ const res = await fetch(url, { headers: { 'X-Cli-Token': config.token } });
61
+ const body = await res.json().catch(() => ({}));
62
+ if (!res.ok) {
63
+ const err = body;
64
+ const suffix = err.errorCode ? ` ${err.errorCode}` : '';
65
+ throw new Error(`[${res.status}${suffix}] ${err.error ?? 'Request failed'}`);
66
+ }
67
+ return body;
68
+ }
69
+ export async function cliPost(config, path, body) {
70
+ const res = await fetch(config.baseUrl + path, {
71
+ method: 'POST',
72
+ headers: { 'X-Cli-Token': config.token, 'Content-Type': 'application/json' },
73
+ body: JSON.stringify(body),
74
+ });
75
+ const responseBody = await res.json().catch(() => ({}));
76
+ if (!res.ok) {
77
+ const err = responseBody;
78
+ const suffix = err.errorCode ? ` ${err.errorCode}` : '';
79
+ throw new Error(`[${res.status}${suffix}] ${err.error ?? 'Request failed'}`);
80
+ }
81
+ return responseBody;
82
+ }
83
+ /** Gửi 1 file local kèm field text (mode/testToConversationId/caption) — fields đi TRƯỚC file trong FormData, khớp thứ tự backend giả định. */
84
+ export async function cliPostFile(config, path, filePath, fields) {
85
+ const buffer = await readFile(filePath);
86
+ const ext = extname(filePath).toLowerCase();
87
+ const mimeType = MIME_BY_EXT[ext] ?? 'application/octet-stream';
88
+ const filename = basename(filePath);
89
+ const formData = new FormData();
90
+ for (const [key, value] of Object.entries(fields)) {
91
+ if (value !== undefined && value !== '')
92
+ formData.append(key, value);
93
+ }
94
+ formData.append('file', new Blob([buffer], { type: mimeType }), filename);
95
+ const res = await fetch(config.baseUrl + path, {
96
+ method: 'POST',
97
+ headers: { 'X-Cli-Token': config.token },
98
+ body: formData,
99
+ });
100
+ const responseBody = await res.json().catch(() => ({}));
101
+ if (!res.ok) {
102
+ const err = responseBody;
103
+ const suffix = err.errorCode ? ` ${err.errorCode}` : '';
104
+ throw new Error(`[${res.status}${suffix}] ${err.error ?? 'Request failed'}`);
105
+ }
106
+ return responseBody;
107
+ }
108
+ //# sourceMappingURL=api-client.js.map
package/dist/config.js ADDED
@@ -0,0 +1,84 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ export class CliConfigError extends Error {
5
+ }
6
+ let warnedLegacyEnv = false;
7
+ let runtimeOverrides = {};
8
+ export function setRuntimeOverrides(input) {
9
+ runtimeOverrides = input;
10
+ }
11
+ export function configDir() {
12
+ if (process.platform === 'win32') {
13
+ const roaming = process.env.APPDATA || join(homedir(), 'AppData', 'Roaming');
14
+ return join(roaming, 'LALO');
15
+ }
16
+ const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
17
+ return join(xdg, 'lalo');
18
+ }
19
+ export function configPath() {
20
+ return join(configDir(), 'config.json');
21
+ }
22
+ export function maskKey(raw) {
23
+ if (!raw)
24
+ return '(chưa có)';
25
+ if (raw.length <= 12)
26
+ return `${raw.slice(0, 4)}…`;
27
+ return `${raw.slice(0, 12)}…`;
28
+ }
29
+ async function readConfigFile() {
30
+ try {
31
+ const raw = await readFile(configPath(), 'utf8');
32
+ const parsed = JSON.parse(raw);
33
+ if (!parsed || typeof parsed !== 'object' || !parsed.profiles) {
34
+ return { profiles: {} };
35
+ }
36
+ return parsed;
37
+ }
38
+ catch {
39
+ return { profiles: {} };
40
+ }
41
+ }
42
+ async function writeConfigFile(file) {
43
+ const dest = configPath();
44
+ await mkdir(dirname(dest), { recursive: true, mode: 0o700 });
45
+ await writeFile(dest, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600, encoding: 'utf8' });
46
+ }
47
+ export async function getProfile(name = 'default') {
48
+ const file = await readConfigFile();
49
+ return file.profiles[name] ?? {};
50
+ }
51
+ export async function setProfileValues(values, name = 'default') {
52
+ const file = await readConfigFile();
53
+ file.profiles[name] = { ...file.profiles[name], ...values };
54
+ await writeConfigFile(file);
55
+ return file.profiles[name];
56
+ }
57
+ export async function unsetProfileKey(field, name = 'default') {
58
+ const file = await readConfigFile();
59
+ if (!file.profiles[name])
60
+ return;
61
+ delete file.profiles[name][field];
62
+ await writeConfigFile(file);
63
+ }
64
+ export async function loadConfig(input = {}) {
65
+ const merged = { ...runtimeOverrides, ...input };
66
+ const profileName = merged.profile || 'default';
67
+ const stored = await getProfile(profileName);
68
+ const legacyUrl = process.env.ZALOCRM_CLI_BASE_URL;
69
+ const legacyToken = process.env.ZALOCRM_CLI_TOKEN;
70
+ if ((legacyUrl || legacyToken) && !warnedLegacyEnv) {
71
+ warnedLegacyEnv = true;
72
+ process.stderr.write('⚠ Biến ZALOCRM_CLI_* sẽ bỏ sau một phiên bản. Đổi sang LALO_BASE_URL / LALO_API_KEY, hoặc `lalo config set`.\n');
73
+ }
74
+ const baseUrl = (merged.server || process.env.LALO_BASE_URL || legacyUrl || stored.server || '').replace(/\/$/, '');
75
+ const token = merged.apiKey || process.env.LALO_API_KEY || legacyToken || stored.apiKey || '';
76
+ if (!baseUrl) {
77
+ throw new CliConfigError('Thiếu địa chỉ máy chủ. Chạy `lalo config set server https://chat.diginno.net` hoặc đặt LALO_BASE_URL.');
78
+ }
79
+ if (!token) {
80
+ throw new CliConfigError('Thiếu khoá API. Chạy `lalo config set api-key <KEY>` (tạo khoá trong LALO › Cài đặt › Khoá API, quyền CLI) hoặc đặt LALO_API_KEY.');
81
+ }
82
+ return { baseUrl, token };
83
+ }
84
+ //# sourceMappingURL=config.js.map
package/dist/index.js ADDED
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { cliGet, cliPost, cliPostFile, fetchAllPages, loadConfigFromEnv } from './api-client.js';
4
+ import { configPath, getProfile, loadConfig, maskKey, setProfileValues, setRuntimeOverrides, unsetProfileKey, } from './config.js';
5
+ import { printContactFacts, printDraftResult, printJson, printScanResult, printSendAttachmentResult, printApplyTagsResult, printSendResult, printSweepResult, printTagCatalog, printTagsOfTarget, printThreadsTable, printWriteContactFacts, printConversationComments, printAddConversationComment, } from './output.js';
6
+ if (process.argv[1]?.includes('zalocrm-cli')) {
7
+ process.stderr.write('⚠ `zalocrm-cli` sẽ bỏ sau một phiên bản. Dùng lệnh `lalo`.\n');
8
+ }
9
+ const program = new Command();
10
+ program
11
+ .name('lalo')
12
+ .description('LALO CLI — đọc chat / ghi hồ sơ / bình luận nội bộ / gửi tin qua LALO. Không tự đăng nhập Zalo. Lệnh cũ `zalocrm-cli` vẫn chạy trong phiên bản này.')
13
+ .version('0.2.0')
14
+ .option('--server <url>', 'Địa chỉ LALO (thắng file cấu hình)')
15
+ .option('--api-key <key>', 'Khoá CLI (thắng file cấu hình, không in ra log)')
16
+ .option('--profile <name>', 'Hồ sơ cấu hình (mặc định: default)')
17
+ .hook('preAction', (thisCommand) => {
18
+ const o = thisCommand.optsWithGlobals();
19
+ setRuntimeOverrides({ server: o.server, apiKey: o.apiKey, profile: o.profile });
20
+ });
21
+ const configCmd = program.command('config').description('Cấu hình máy chủ và khoá API trên máy này');
22
+ configCmd
23
+ .command('set')
24
+ .description('Lưu server hoặc api-key vào hồ sơ local (file chỉ user đọc được)')
25
+ .argument('<field>', 'server | api-key')
26
+ .argument('<value>', 'Giá trị')
27
+ .action(async (field, value, _opts, cmd) => {
28
+ const profile = cmd.optsWithGlobals().profile || 'default';
29
+ if (field !== 'server' && field !== 'api-key') {
30
+ console.error('Chỉ nhận field `server` hoặc `api-key`.');
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+ const saved = await setProfileValues(field === 'server' ? { server: value } : { apiKey: value }, profile);
35
+ if (field === 'api-key') {
36
+ console.log(`Đã lưu khoá ${maskKey(saved.apiKey)} vào hồ sơ \`${profile}\`.`);
37
+ }
38
+ else {
39
+ console.log(`Đã lưu server ${saved.server} vào hồ sơ \`${profile}\`.`);
40
+ }
41
+ });
42
+ configCmd
43
+ .command('show')
44
+ .description('Hiện cấu hình đang dùng — khoá chỉ hiện prefix')
45
+ .action(async (_opts, cmd) => {
46
+ const profile = cmd.optsWithGlobals().profile || 'default';
47
+ const stored = await getProfile(profile);
48
+ let resolved = null;
49
+ try {
50
+ resolved = await loadConfig({ profile });
51
+ }
52
+ catch {
53
+ resolved = null;
54
+ }
55
+ console.log(`Hồ sơ: ${profile}`);
56
+ console.log(`File: ${configPath()}`);
57
+ console.log(`Server đã lưu: ${stored.server || '(chưa có)'}`);
58
+ console.log(`Khoá đã lưu: ${maskKey(stored.apiKey)}`);
59
+ if (resolved) {
60
+ console.log(`Server hiệu lực: ${resolved.baseUrl}`);
61
+ console.log(`Khoá hiệu lực: ${maskKey(resolved.token)}`);
62
+ }
63
+ else {
64
+ console.log('Chưa đủ server + khoá để gọi máy chủ.');
65
+ }
66
+ });
67
+ configCmd
68
+ .command('unset')
69
+ .description('Xóa server hoặc api-key khỏi hồ sơ local')
70
+ .argument('<field>', 'server | api-key')
71
+ .action(async (field, _opts, cmd) => {
72
+ const profile = cmd.optsWithGlobals().profile || 'default';
73
+ if (field !== 'server' && field !== 'api-key') {
74
+ console.error('Chỉ nhận field `server` hoặc `api-key`.');
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ await unsetProfileKey(field === 'server' ? 'server' : 'apiKey', profile);
79
+ console.log(`Đã xóa \`${field}\` khỏi hồ sơ \`${profile}\`.`);
80
+ });
81
+ program
82
+ .command('auth')
83
+ .description('Kiểm tra khoá CLI với máy chủ')
84
+ .command('status')
85
+ .description('Kiểm tra khoá: tổ chức, nick được phép, quyền, hạn dùng — không in khoá thô')
86
+ .option('--json', 'In JSON đầy đủ')
87
+ .action(async (opts) => {
88
+ const config = await loadConfigFromEnv();
89
+ const data = await cliGet(config, '/api/cli/auth/status');
90
+ if (opts.json) {
91
+ console.log(JSON.stringify(data, null, 2));
92
+ return;
93
+ }
94
+ console.log(`Tổ chức: ${data.org.name ?? data.org.id}`);
95
+ console.log(`Khoá: ${data.key.name} (${data.key.prefix}…) · ${data.key.kind}`);
96
+ console.log(`Quyền: ${data.key.scopes.join(', ') || '(không)'}`);
97
+ console.log(`Hạn dùng: ${data.key.expiresAt ?? 'không hết hạn'}`);
98
+ if (data.user)
99
+ console.log(`Người: ${data.user.name} <${data.user.email}>`);
100
+ if (data.nicks.all)
101
+ console.log('Nick: mọi nick trong tổ chức');
102
+ else if (data.nicks.items.length === 0)
103
+ console.log('Nick: (danh sách trống)');
104
+ else {
105
+ console.log('Nick:');
106
+ for (const nick of data.nicks.items) {
107
+ console.log(` - ${nick.name ?? nick.id}`);
108
+ }
109
+ }
110
+ });
111
+ const conv = program.command('conv').description('Hội thoại (conversation/thread) của 1 nick Zalo');
112
+ conv
113
+ .command('list')
114
+ .description('Liệt kê hội thoại (DM hoặc nhóm) của 1 nick Zalo, mới nhất trước')
115
+ .requiredOption('--account <zaloAccountId>', 'ID Zalo account (nick) trong LALO')
116
+ .option('--type <type>', 'group | user | all', 'all')
117
+ .option('--search <text>', 'Lọc theo tên nhóm/tên khách')
118
+ .option('--limit <n>', 'Số hội thoại mỗi trang (mặc định 30, tối đa 100)', '30')
119
+ .option('--offset <n>', 'Bỏ qua N hội thoại đầu — dùng để lật trang tiếp theo', '0')
120
+ .option('--all', 'Tự lật hết mọi trang cho tới khi hết hội thoại (bỏ qua --offset)')
121
+ .option('--json', 'In JSON đầy đủ thay vì bảng gọn')
122
+ .action(async (opts) => {
123
+ const config = await loadConfigFromEnv();
124
+ const fetchPage = (offset) => cliGet(config, `/api/cli/zalo-accounts/${opts.account}/threads`, {
125
+ threadType: opts.type,
126
+ search: opts.search,
127
+ limit: opts.limit,
128
+ offset,
129
+ });
130
+ const data = opts.all
131
+ ? await fetchAllPages(fetchPage, (d) => d.threads, (d, threads) => ({ ...d, threads, offset: 0, hasMore: false }))
132
+ : await fetchPage(Number(opts.offset) || 0);
133
+ if (opts.json)
134
+ printJson(data);
135
+ else
136
+ printThreadsTable(data);
137
+ });
138
+ conv
139
+ .command('scan')
140
+ .description('Đọc tin nhắn gần đây trong 1 hội thoại (DM hoặc nhóm), cũ → mới')
141
+ .requiredOption('--conversation <conversationId>', 'ID hội thoại trong LALO (lấy từ `conv list`)')
142
+ .option('--limit <n>', 'Số tin mỗi trang (mặc định 50, tối đa 200)', '50')
143
+ .option('--offset <n>', 'Bỏ qua N tin mới nhất — dùng để lấy tiếp phần cũ hơn', '0')
144
+ .option('--all', 'Tự lật hết mọi trang cho tới khi hết tin trong khung giờ (bỏ qua --offset)')
145
+ .option('--since-hours <n>', 'Chỉ lấy tin trong N giờ gần đây')
146
+ .option('--search <text>', 'Chỉ lấy tin có chứa từ khoá này (không phân biệt hoa/thường)')
147
+ .option('--json', 'In JSON đầy đủ thay vì danh sách gọn')
148
+ .action(async (opts) => {
149
+ const config = await loadConfigFromEnv();
150
+ const fetchPage = (offset) => cliGet(config, `/api/cli/conversations/${opts.conversation}/messages`, {
151
+ limit: opts.limit,
152
+ offset,
153
+ sinceHours: opts.sinceHours,
154
+ search: opts.search,
155
+ });
156
+ // Mỗi trang đã đảo cũ→mới; trang sau là tin CŨ HƠN nên phải ghép lên trước.
157
+ const data = opts.all
158
+ ? await fetchAllPages(fetchPage, (d) => d.messages, (d, pages) => ({
159
+ ...d, messages: pages, offset: 0, hasMore: false, truncated: false,
160
+ }), 'prepend')
161
+ : await fetchPage(Number(opts.offset) || 0);
162
+ if (opts.json)
163
+ printJson(data);
164
+ else
165
+ printScanResult(data);
166
+ });
167
+ conv
168
+ .command('sweep')
169
+ .description('Quét MỌI hội thoại có tin trong N giờ gần đây (mặc định 24h), gom theo hội thoại kèm nguồn — dùng để tóm tắt bản tin tổng hợp')
170
+ .option('--account <zaloAccountId>', 'Chỉ quét 1 nick; bỏ trống = quét mọi nick trong org')
171
+ .option('--since-hours <n>', 'Khung giờ quét ngược về trước (mặc định 24)', '24')
172
+ .option('--type <type>', 'group | user | all', 'all')
173
+ .option('--search <text>', 'Chỉ lấy tin có chứa từ khoá này')
174
+ .option('--limit <n>', 'Trần TỔNG số tin lấy về, không phải mỗi hội thoại (mặc định 500, tối đa 2000)', '500')
175
+ .option('--json', 'In JSON đầy đủ thay vì bản tóm gọn')
176
+ .action(async (opts) => {
177
+ const config = await loadConfigFromEnv();
178
+ const data = await cliGet(config, '/api/cli/sweep', {
179
+ zaloAccountId: opts.account,
180
+ sinceHours: opts.sinceHours,
181
+ threadType: opts.type,
182
+ search: opts.search,
183
+ limit: opts.limit,
184
+ });
185
+ if (opts.json)
186
+ printJson(data);
187
+ else
188
+ printSweepResult(data);
189
+ });
190
+ const contact = program
191
+ .command('contact')
192
+ .description('Hồ sơ khách của 1 hội thoại — ghi nhớ ("sự thật bền") mà AI đọc lại ở mọi lần trả lời sau');
193
+ contact
194
+ .command('facts')
195
+ .description('Xem các ghi nhớ đang lưu về khách của 1 hội thoại (DM). Chạy lệnh này TRƯỚC khi ghi thêm để không ghi trùng.')
196
+ .requiredOption('--conversation <conversationId>', 'ID hội thoại trong LALO (lấy từ `conv list`)')
197
+ .option('--json', 'In JSON đầy đủ')
198
+ .action(async (opts) => {
199
+ const config = await loadConfigFromEnv();
200
+ const data = await cliGet(config, `/api/cli/conversations/${opts.conversation}/contact-facts`);
201
+ if (opts.json)
202
+ printJson(data);
203
+ else
204
+ printContactFacts(data);
205
+ });
206
+ contact
207
+ .command('fact-add')
208
+ .description('Ghi THẲNG ghi nhớ mới về khách của hội thoại này — ghi luôn, không có bước duyệt. Mỗi câu vào sổ lịch sử (kèm hội thoại nguồn) và hoàn tác được trong app.')
209
+ .requiredOption('--conversation <conversationId>', 'ID hội thoại trong LALO (lấy từ `conv list`)')
210
+ .requiredOption('--text <text...>', 'Câu cần nhớ (≤300 ký tự). Lặp lại --text để ghi nhiều câu, tối đa 10 câu/lần.')
211
+ .option('--json', 'In JSON đầy đủ')
212
+ .action(async (opts) => {
213
+ const texts = Array.isArray(opts.text) ? opts.text : [opts.text];
214
+ const config = await loadConfigFromEnv();
215
+ const data = await cliPost(config, `/api/cli/conversations/${opts.conversation}/contact-facts`, { texts });
216
+ if (opts.json)
217
+ printJson(data);
218
+ else
219
+ printWriteContactFacts(data);
220
+ });
221
+ const tag = program.command('tag').description('Tag CRM — xem danh mục và gắn tag sau khi đọc chat');
222
+ tag
223
+ .command('list')
224
+ .description('Danh mục tag CRM của org, kèm chính sách AI từng tag (auto/suggest/off) — chạy TRƯỚC khi gắn')
225
+ .option('--include-off', 'Hiện cả tag cấm AI gắn (mặc định ẩn)')
226
+ .option('--json', 'In JSON đầy đủ thay vì bảng gọn')
227
+ .action(async (opts) => {
228
+ const config = await loadConfigFromEnv();
229
+ const data = await cliGet(config, '/api/cli/tags', {
230
+ includeOff: opts.includeOff ? 'true' : undefined,
231
+ });
232
+ if (opts.json)
233
+ printJson(data);
234
+ else
235
+ printTagCatalog(data);
236
+ });
237
+ tag
238
+ .command('show')
239
+ .description('Tag đang có của 1 hội thoại + đề xuất đang chờ duyệt')
240
+ .requiredOption('--conversation <conversationId>', 'ID hội thoại trong LALO')
241
+ .option('--json', 'In JSON đầy đủ')
242
+ .action(async (opts) => {
243
+ const config = await loadConfigFromEnv();
244
+ const data = await cliGet(config, `/api/cli/conversations/${opts.conversation}/tags`);
245
+ if (opts.json)
246
+ printJson(data);
247
+ else
248
+ printTagsOfTarget(data);
249
+ });
250
+ tag
251
+ .command('apply')
252
+ .description('Gắn tag sau khi đọc chat. Tag nhóm an toàn vào thẳng; tag trạng thái vào hàng chờ sale duyệt')
253
+ .requiredOption('--conversation <conversationId>', 'ID hội thoại trong LALO')
254
+ .requiredOption('--tag <name...>', 'Tên tag ĐÚNG như trong danh mục. Lặp lại --tag để gắn nhiều tag, tối đa 10.')
255
+ .option('--reason <text...>', 'Căn cứ rút từ hội thoại — người duyệt cần biết vì sao. Khớp theo thứ tự với --tag.')
256
+ .option('--source-message <id...>', 'ID tin nhắn làm bằng chứng. Khớp theo thứ tự với --tag.')
257
+ .option('--json', 'In JSON đầy đủ')
258
+ .action(async (opts) => {
259
+ const config = await loadConfigFromEnv();
260
+ const asList = (v) => (Array.isArray(v) ? v : v ? [String(v)] : []);
261
+ const reasons = asList(opts.reason);
262
+ const sources = asList(opts.sourceMessage);
263
+ const tags = asList(opts.tag).map((name, i) => ({
264
+ name,
265
+ reason: reasons[i],
266
+ sourceMessageId: sources[i],
267
+ }));
268
+ const data = await cliPost(config, `/api/cli/conversations/${opts.conversation}/tags`, { tags });
269
+ if (opts.json)
270
+ printJson(data);
271
+ else
272
+ printApplyTagsResult(data);
273
+ });
274
+ const comment = program
275
+ .command('comment')
276
+ .description('Bình luận nội bộ trên hội thoại — sale thấy cột phải khung chat. KHÔNG gửi Zalo.');
277
+ comment
278
+ .command('list')
279
+ .description('Đọc bình luận nội bộ đang có trên 1 hội thoại. Chạy TRƯỚC khi ghi thêm để không trùng.')
280
+ .requiredOption('--conversation <conversationId>', 'ID hội thoại trong LALO (lấy từ `conv list`)')
281
+ .option('--json', 'In JSON đầy đủ')
282
+ .action(async (opts) => {
283
+ const config = await loadConfigFromEnv();
284
+ const data = await cliGet(config, `/api/cli/conversations/${opts.conversation}/comments`);
285
+ if (opts.json)
286
+ printJson(data);
287
+ else
288
+ printConversationComments(data);
289
+ });
290
+ comment
291
+ .command('add')
292
+ .description('Ghi THẲNG bình luận nội bộ vào hội thoại — ghi luôn, không gửi Zalo. Sale thấy cột phải.')
293
+ .requiredOption('--conversation <conversationId>', 'ID hội thoại trong LALO')
294
+ .requiredOption('--text <text>', 'Nội dung bình luận (≤5000 ký tự)')
295
+ .option('--reply-to <commentId>', 'Trả lời 1 bình luận gốc (1 cấp). Lấy id từ `comment list`.')
296
+ .option('--json', 'In JSON đầy đủ')
297
+ .action(async (opts) => {
298
+ const config = await loadConfigFromEnv();
299
+ const data = await cliPost(config, `/api/cli/conversations/${opts.conversation}/comments`, { body: opts.text, parentCommentId: opts.replyTo });
300
+ if (opts.json)
301
+ printJson(data);
302
+ else
303
+ printAddConversationComment(data);
304
+ });
305
+ const msg = program.command('msg').description('Gửi tin nhắn Zalo (qua LALO, có bước duyệt trước khi gửi thật)');
306
+ msg
307
+ .command('send')
308
+ .description('Gửi tin nhắn text. Mặc định CHỈ XEM TRƯỚC, không gửi gì cả — phải chọn --test-to hoặc --confirm mới thật sự gửi. "Cloud của tôi" KHÔNG được Zalo hỗ trợ gửi qua API, dùng --test-to trỏ tới 1 hội thoại an toàn khác thay thế.')
309
+ .requiredOption('--conversation <conversationId>', 'ID hội thoại trong LALO (lấy từ `conv list`)')
310
+ .requiredOption('--text <text>', 'Nội dung tin nhắn')
311
+ .option('--test-to <conversationId>', 'Gửi THẬT nhưng sang 1 hội thoại test khác (cùng nick) — không tới người nhận thật. Hỏi người vận hành hội thoại nào an toàn để dùng, đừng tự đoán.')
312
+ .option('--confirm', 'Gửi THẬT tới đúng hội thoại — chỉ dùng khi người vận hành đã xác nhận muốn gửi')
313
+ .option('--json', 'In JSON đầy đủ')
314
+ .action(async (opts) => {
315
+ if (opts.testTo && opts.confirm) {
316
+ console.error('Lỗi: chỉ chọn 1 trong --test-to hoặc --confirm, không dùng cả hai cùng lúc.');
317
+ process.exitCode = 1;
318
+ return;
319
+ }
320
+ const mode = opts.confirm ? 'confirm' : opts.testTo ? 'test_to' : 'dry_run';
321
+ const config = await loadConfigFromEnv();
322
+ const data = await cliPost(config, `/api/cli/conversations/${opts.conversation}/send`, {
323
+ text: opts.text,
324
+ mode,
325
+ testToConversationId: opts.testTo,
326
+ });
327
+ if (opts.json)
328
+ printJson(data);
329
+ else
330
+ printSendResult(data);
331
+ });
332
+ msg
333
+ .command('draft')
334
+ .description('Đặt tin NHÁP vào hội thoại để người vận hành duyệt trong app. Không gửi gì cho khách. Dùng thay `msg send` khi muốn sale thấy và bấm gửi thay vì mày tự gửi.')
335
+ .requiredOption('--conversation <conversationId>', 'ID hội thoại trong LALO (lấy từ `conv list`)')
336
+ .requiredOption('--text <text>', 'Nội dung tin nháp')
337
+ .option('--label <label>', 'Ghi chú ngắn hiện trên thẻ nháp, vd "trả lời hỏi giá"')
338
+ .option('--json', 'In JSON đầy đủ')
339
+ .action(async (opts) => {
340
+ const config = await loadConfigFromEnv();
341
+ const data = await cliPost(config, `/api/cli/conversations/${opts.conversation}/draft`, {
342
+ content: opts.text,
343
+ label: opts.label,
344
+ });
345
+ if (opts.json)
346
+ printJson(data);
347
+ else
348
+ printDraftResult(data);
349
+ });
350
+ msg
351
+ .command('send-file')
352
+ .description('Gửi ảnh/video/file đính kèm (đọc file trên máy mày, upload qua LALO). Cùng luật xem trước/test-to/confirm như `msg send`.')
353
+ .requiredOption('--conversation <conversationId>', 'ID hội thoại trong LALO (lấy từ `conv list`)')
354
+ .requiredOption('--file <localPath>', 'Đường dẫn file trên máy mày (ảnh: jpg/png/webp/gif ≤100MB, video: mp4/mov/webm ≤500MB, file: pdf/doc/xls/zip... ≤1GB)')
355
+ .option('--caption <text>', 'Chú thích kèm theo (không áp dụng cho ảnh gửi nhiều tấm)')
356
+ .option('--test-to <conversationId>', 'Gửi THẬT nhưng sang 1 hội thoại test khác (cùng nick) — không tới người nhận thật. Hỏi người vận hành hội thoại nào an toàn để dùng, đừng tự đoán.')
357
+ .option('--confirm', 'Gửi THẬT tới đúng hội thoại — chỉ dùng khi người vận hành đã xác nhận muốn gửi')
358
+ .option('--json', 'In JSON đầy đủ')
359
+ .action(async (opts) => {
360
+ if (opts.testTo && opts.confirm) {
361
+ console.error('Lỗi: chỉ chọn 1 trong --test-to hoặc --confirm, không dùng cả hai cùng lúc.');
362
+ process.exitCode = 1;
363
+ return;
364
+ }
365
+ const mode = opts.confirm ? 'confirm' : opts.testTo ? 'test_to' : 'dry_run';
366
+ const config = await loadConfigFromEnv();
367
+ const data = await cliPostFile(config, `/api/cli/conversations/${opts.conversation}/send-attachment`, opts.file, {
368
+ mode,
369
+ testToConversationId: opts.testTo,
370
+ caption: opts.caption,
371
+ });
372
+ if (opts.json)
373
+ printJson(data);
374
+ else
375
+ printSendAttachmentResult(data);
376
+ });
377
+ program.parseAsync(process.argv).catch((err) => {
378
+ console.error(`Lỗi: ${err instanceof Error ? err.message : String(err)}`);
379
+ process.exitCode = 1;
380
+ });
381
+ //# sourceMappingURL=index.js.map
package/dist/output.js ADDED
@@ -0,0 +1,206 @@
1
+ export function printJson(data) {
2
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
3
+ }
4
+ /** UTC ISO (từ backend) → giờ VN (Asia/Ho_Chi_Minh, +07) cho output đọc bằng mắt. --json vẫn giữ UTC. */
5
+ function formatVnTime(iso) {
6
+ const parts = new Intl.DateTimeFormat('sv-SE', {
7
+ timeZone: 'Asia/Ho_Chi_Minh',
8
+ year: 'numeric', month: '2-digit', day: '2-digit',
9
+ hour: '2-digit', minute: '2-digit', hour12: false,
10
+ }).formatToParts(new Date(iso));
11
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? '';
12
+ return `${get('day')}/${get('month')} ${get('hour')}:${get('minute')} +07`;
13
+ }
14
+ export function printThreadsTable(res) {
15
+ const threads = res.threads;
16
+ if (!threads.length) {
17
+ console.log('(không có hội thoại nào)');
18
+ return;
19
+ }
20
+ for (const t of threads) {
21
+ const label = t.threadType === 'group'
22
+ ? (t.groupName ?? '(nhóm chưa có tên)')
23
+ : (t.contact?.fullName ?? t.externalThreadId ?? t.id);
24
+ const badge = t.threadType === 'group' ? `group·${t.groupMembersCount ?? '?'} người` : 'dm';
25
+ const unread = t.unreadCount ? ` · ${t.unreadCount} chưa đọc` : '';
26
+ const time = t.lastMessageAt ? formatVnTime(t.lastMessageAt) : 'chưa có tin';
27
+ console.log(`${t.id} [${badge}] ${label}${unread} (${time})`);
28
+ }
29
+ const shown = res.offset + threads.length;
30
+ console.log(`— ${res.offset + 1}-${shown} / ${res.total} hội thoại`);
31
+ if (res.hasMore) {
32
+ console.log(`⚠ CÒN ${res.total - shown} hội thoại chưa lấy. Trang tiếp: --offset ${shown}. Lấy hết 1 lần: --all.`);
33
+ }
34
+ }
35
+ function formatMessageLine(m) {
36
+ const who = m.senderName ?? m.senderType;
37
+ const quoteTag = m.quote ? '[trả lời] ' : '';
38
+ // preview đã xử lý mọi loại tin; link tải chỉ thêm cho ảnh/video/tệp vì đó là
39
+ // thứ người vận hành thật sự mở lại.
40
+ const href = m.attachment?.href ? ` ${m.attachment.href}` : '';
41
+ const body = m.preview || `[${m.contentType}]`;
42
+ return `${formatVnTime(m.sentAt)} ${who}: ${quoteTag}${body}${href}`;
43
+ }
44
+ export function printScanResult(res) {
45
+ if (!res.messages.length) {
46
+ console.log('(không có tin nhắn nào trong khoảng thời gian này)');
47
+ return;
48
+ }
49
+ for (const m of res.messages) {
50
+ console.log(formatMessageLine(m));
51
+ }
52
+ const shown = res.offset + res.messages.length;
53
+ console.log(`— ${res.messages.length}/${res.totalInWindow} tin trong khung giờ`);
54
+ if (res.hasMore) {
55
+ console.log(`⚠ CÒN ${res.totalInWindow - shown} tin CŨ HƠN chưa lấy. Trang tiếp: --offset ${shown}. Lấy hết 1 lần: --all.`);
56
+ }
57
+ }
58
+ export function printSweepResult(res) {
59
+ if (!res.conversationCount) {
60
+ console.log(`(không có tin nhắn nào trong ${res.sinceHours} giờ qua)`);
61
+ return;
62
+ }
63
+ console.log(`${res.returned} tin · ${res.conversationCount} hội thoại · ${res.sinceHours} giờ qua\n`);
64
+ for (const c of res.conversations) {
65
+ const kind = c.threadType === 'group' ? 'nhóm' : 'khách';
66
+ const via = c.accountName ? ` · qua nick "${c.accountName}"` : '';
67
+ const unread = c.unreadCount ? ` · ${c.unreadCount} chưa đọc` : '';
68
+ console.log(`▸ [${kind}] ${c.label}${via} — ${c.messageCount} tin${unread}`);
69
+ console.log(` (${c.conversationId})`);
70
+ for (const m of c.messages)
71
+ console.log(` ${formatMessageLine(m)}`);
72
+ console.log('');
73
+ }
74
+ if (res.hasMore) {
75
+ console.log(`⚠ CHẠM TRẦN: chỉ lấy ${res.returned}/${res.totalInWindow} tin trong khung giờ — phần CŨ nhất bị bỏ.`);
76
+ console.log(' Tăng --limit (tối đa 2000), thu hẹp --since-hours, hoặc quét từng nick bằng --account.');
77
+ }
78
+ }
79
+ const POLICY_LABEL = {
80
+ auto: 'tự gắn được',
81
+ suggest: 'phải duyệt',
82
+ off: 'CẤM AI gắn',
83
+ };
84
+ export function printTagCatalog(res) {
85
+ if (!res.total) {
86
+ console.log('(org chưa có tag CRM nào)');
87
+ return;
88
+ }
89
+ let currentGroup;
90
+ for (const t of res.tags) {
91
+ if (t.group !== currentGroup) {
92
+ currentGroup = t.group;
93
+ console.log(`\n▸ ${currentGroup ?? '(không nhóm)'}`);
94
+ }
95
+ const emoji = t.emoji ? `${t.emoji} ` : '';
96
+ const zalo = t.managedByZalo ? ' · nhãn Zalo' : '';
97
+ console.log(` ${emoji}${t.name} [${POLICY_LABEL[t.aiPolicy] ?? t.aiPolicy}]${zalo}`);
98
+ if (t.description)
99
+ console.log(` ${t.description}`);
100
+ }
101
+ console.log(`\n${res.total} tag khả dụng.`);
102
+ }
103
+ export function printTagsOfTarget(res) {
104
+ const kind = res.targetType === 'conversation' ? 'nhóm' : 'khách';
105
+ console.log(`Tag của ${kind} "${res.label}":`);
106
+ console.log(res.tags.length ? ` ${res.tags.join(' · ')}` : ' (chưa có tag nào)');
107
+ if (res.pendingSuggestions.length) {
108
+ console.log(`\n${res.pendingSuggestions.length} đề xuất đang chờ duyệt:`);
109
+ for (const s of res.pendingSuggestions)
110
+ console.log(` ⏳ ${s.tagName} — ${s.reason}`);
111
+ console.log(' (sale duyệt trong app: hồ sơ khách › tag AI đề xuất)');
112
+ }
113
+ }
114
+ export function printApplyTagsResult(res) {
115
+ const kind = res.targetType === 'conversation' ? 'nhóm' : 'khách';
116
+ console.log(`${kind} "${res.label}":`);
117
+ if (res.applied.length)
118
+ console.log(` ✓ Đã gắn thẳng: ${res.applied.join(' · ')}`);
119
+ if (res.suggested.length) {
120
+ console.log(` ⏳ Chờ sale duyệt: ${res.suggested.map((s) => s.name).join(' · ')}`);
121
+ }
122
+ if (res.alreadyHad.length)
123
+ console.log(` = Đã có sẵn, bỏ qua: ${res.alreadyHad.join(' · ')}`);
124
+ for (const r of res.rejected)
125
+ console.log(` ✗ Từ chối "${r.name}" — ${r.why}`);
126
+ if (!res.applied.length && !res.suggested.length)
127
+ return;
128
+ console.log(` Tag hiện tại: ${res.tags.join(' · ') || '(trống)'}`);
129
+ }
130
+ export function printSendResult(res) {
131
+ if (res.mode === 'dry_run') {
132
+ console.log(`[XEM TRƯỚC — CHƯA GỬI] tới "${res.target.label}": "${res.text ?? ''}"`);
133
+ console.log('Muốn gửi thử sang hội thoại khác: thêm --test-to <conversationId>. Muốn gửi thật: thêm --confirm.');
134
+ return;
135
+ }
136
+ if (res.mode === 'test_to') {
137
+ console.log(`✓ Đã gửi THỬ tới "${res.testTarget?.label ?? '(hội thoại test)'}" — CHƯA gửi tới "${res.target.label}".`);
138
+ console.log('Ưng nội dung rồi thì chạy lại với --confirm để gửi thật.');
139
+ return;
140
+ }
141
+ console.log(`✓ Đã gửi THẬT tới "${res.target.label}"${res.zaloMsgId ? ` (msgId=${res.zaloMsgId})` : ''}.`);
142
+ }
143
+ export function printSendAttachmentResult(res) {
144
+ if (res.mode === 'dry_run') {
145
+ console.log(`[XEM TRƯỚC — CHƯA GỬI] ${res.kind} tới "${res.target.label}"`);
146
+ console.log('Muốn gửi thử sang hội thoại khác: thêm --test-to <conversationId>. Muốn gửi thật: thêm --confirm.');
147
+ return;
148
+ }
149
+ if (res.mode === 'test_to') {
150
+ console.log(`✓ Đã gửi THỬ (${res.kind}) tới "${res.testTarget?.label ?? '(hội thoại test)'}" — CHƯA gửi tới "${res.target.label}".`);
151
+ console.log('Ưng rồi thì chạy lại với --confirm để gửi thật.');
152
+ return;
153
+ }
154
+ console.log(`✓ Đã gửi THẬT (${res.kind}) tới "${res.target.label}"${res.zaloMsgId ? ` (msgId=${res.zaloMsgId})` : ''}.`);
155
+ }
156
+ export function printContactFacts(res) {
157
+ const who = res.contactName || '(chưa có tên)';
158
+ if (!res.facts.length) {
159
+ console.log(`Chưa có ghi nhớ nào về "${who}".`);
160
+ return;
161
+ }
162
+ console.log(`${res.facts.length} ghi nhớ về "${who}":`);
163
+ for (const f of res.facts) {
164
+ console.log(` ${f.source === 'ai' ? '[AI] ' : '[Người]'} ${f.text}`);
165
+ }
166
+ }
167
+ export function printWriteContactFacts(res) {
168
+ const who = res.contactName || '(chưa có tên)';
169
+ if (!res.written) {
170
+ console.log(`Không ghi gì mới về "${who}" — ${res.skippedDuplicates} câu đã có sẵn trong hồ sơ.`);
171
+ return;
172
+ }
173
+ console.log(`✓ Đã ghi ${res.written} ghi nhớ vào hồ sơ "${who}":`);
174
+ for (const f of res.facts)
175
+ console.log(` + ${f.text}`);
176
+ if (res.skippedDuplicates)
177
+ console.log(` (bỏ qua ${res.skippedDuplicates} câu đã có sẵn)`);
178
+ console.log('Xem lại/hoàn tác trong LALO > hồ sơ khách > Sự thật về người này > Lịch sử.');
179
+ }
180
+ export function printDraftResult(res) {
181
+ console.log(`Đã đặt nháp vào hội thoại${res.threadName ? ` "${res.threadName}"` : ''}.`);
182
+ console.log(` ${res.content}`);
183
+ console.log('Nháp KHÔNG tự gửi — người vận hành mở chat, bấm "Duyệt & gửi" mới tới khách.');
184
+ }
185
+ function printCommentLine(c, indent = '') {
186
+ console.log(`${indent}${formatVnTime(c.createdAt)} ${c.authorName}: ${c.body}`);
187
+ console.log(`${indent} (${c.id})`);
188
+ for (const r of c.replies || [])
189
+ printCommentLine(r, `${indent} `);
190
+ }
191
+ export function printConversationComments(res) {
192
+ if (!res.total) {
193
+ console.log(`Chưa có bình luận nội bộ nào trên "${res.label}".`);
194
+ return;
195
+ }
196
+ console.log(`${res.total} bình luận nội bộ trên "${res.label}" (không gửi Zalo):`);
197
+ for (const c of res.comments)
198
+ printCommentLine(c);
199
+ }
200
+ export function printAddConversationComment(res) {
201
+ const kind = res.comment.parentId ? 'trả lời' : 'bình luận';
202
+ console.log(`✓ Đã ghi ${kind} nội bộ vào "${res.label}". Khách Zalo KHÔNG nhận tin này.`);
203
+ console.log(` ${res.comment.body}`);
204
+ console.log('Sale thấy ở cột phải khung chat (Bình luận hội thoại).');
205
+ }
206
+ //# sourceMappingURL=output.js.map
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "lalo-cli",
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "license": "Apache-2.0",
6
+ "description": "LALO CLI — đọc hội thoại Zalo, ghi hồ sơ, bình luận nội bộ và gửi tin (có duyệt) qua LALO. Không tự đăng nhập Zalo.",
7
+ "type": "module",
8
+ "bin": {
9
+ "lalo": "dist/index.js",
10
+ "zalocrm-cli": "dist/index.js"
11
+ },
12
+ "main": "dist/index.js",
13
+ "files": [
14
+ "dist/**/*.js",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "dev": "tsx src/index.ts",
21
+ "test": "tsx --test test/config.test.ts",
22
+ "pack:check": "npm pack --dry-run"
23
+ },
24
+ "dependencies": {
25
+ "commander": "^12.1.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^25.5.0",
29
+ "@zalocrm/contracts": "file:../shared/contracts",
30
+ "tsx": "^4.21.0",
31
+ "typescript": "^6.0.2"
32
+ }
33
+ }