Skill: vt-mmm/telegram-contract-ops (version 1.0.1)

--- name: telegram-contract-ops description: Telegram-based internal contract generation and eID intake workflow for Vietnamese operations. Use when setting up, migrating, or operating a bot that: (1) receives structured contract input in Telegram, (2) generates `.docx` contracts from a fixed company template, (3) receives Vietnamese electronic ID screenshots, OCRs key fields, and converts them into contract-ready input, and (4) routes behavior by Telegram group for contract workflows. --- # Telegram Contract Ops Use this skill for the combined Plan B + Plan C workflow. ## What this skill covers - Parse standardized Telegram text blocks into contract data - Generate `.docx` contracts from a company template - Run Telegram bot flows for `/mauhopdong`, `/cccd`, `/cccd_debug` - OCR Vietnamese electronic ID screenshots and convert them into Plan B input blocks - Route contract behavior by Telegram group ## Current design boundary ### Plan B - Input: structured Telegram-style `KEY: VALUE` block - Output: `.docx` contract file ### Plan C - Input: 1 screenshot of Vietnamese electronic ID from app - OCR output fields: - `TEN` - `NGAY_SINH` - `CCCD` - `NGAY_CAP` - `THUONG_TRU` - `CHO_O_HIEN_TAI` when available - Hardcode: - `NOI_CAP = CTCCS QLHC VTTXH` - User confirms/edits block before final contract generation ## Keep these outside the packaged skill - Telegram bot token - group chat IDs - `.env.telegram` - `.env.telegram.groups` - OCR debug session artifacts - state files and logs ## Included references - `references/architecture.md` - flow overview and routing - `references/deployment.md` - migration/install checklist - `references/input-template.md` - canonical Telegram input block - `references/clawhub.md` - publish/install/update commands via ClawHub - `references/troubleshooting.md` - common runtime and routing issues - `references/windows.md` - Windows-specific guidance - `references/macos.md` - macOS-specific guidance ## Included assets/scripts Read these only when needed: - `scripts/` for generator/parser/bot helpers - `assets/` for original `.docx` template - `assets/windows/` for Windows example env files and quick-start PowerShell wrapper

path: assets/plan-b-telegram-template.txt
content: TEN:
NGAY_SINH:
CCCD:
NGAY_CAP:
NOI_CAP:
THUONG_TRU:
CHO_O_HIEN_TAI:
DIEN_THOAI:
EMAIL:
STK:
NGAN_HANG:
DU_AN:
CONG_VIEC:
TU_NGAY:
DEN_NGAY:
SO_TIEN:
SO_TIEN_CHU:
THANH_TOAN:


path: references/architecture.md
content: # Telegram Contract Ops Architecture

## Purpose
Handle internal contract creation in Telegram.

## Groups
- Management group: reminders only (Plan A, outside this skill)
- Contract group: contract generation + eID OCR intake

## Plan B
1. User requests `/mauhopdong`
2. Bot returns canonical input block
3. User pastes filled block
4. Parser validates and maps data
5. Generator produces `.docx`
6. Bot returns file to Telegram

## Plan C
1. User sends `/cccd` or `/cccd_debug`
2. Bot asks for one electronic ID screenshot from app
3. OCR runs using Apple Vision
4. eID parser extracts main fields
5. Bot returns editable Plan B block
6. User sends corrected/completed block
7. Plan B generator returns `.docx`

## Current implementation assets in workspace
- `telegram-planb-bot.js`
- `plan-b-telegram-template.txt`
- `plan-b-telegram-to-docx.js`
- `plan-b-docx-generate.py`
- `plan-c-ocr.swift`
- `plan-c-eid-parse.js`


path: references/clawhub.md
content: # ClawHub Publish / Install Notes

## Install CLI

```bash
npm i -g clawhub
```

## Login for publish

```bash
clawhub login
clawhub whoami
```

## Publish this skill

```bash
clawhub publish ./skills/telegram-contract-ops --slug telegram-contract-ops --name "Telegram Contract Ops" --version 1.0.0 --changelog "Initial release: Telegram contract generation, eID OCR intake, structured docx output"
```

## Install on a new machine

```bash
npm i -g clawhub
clawhub install telegram-contract-ops
```

## Upgrade on a machine that already has it

```bash
clawhub update telegram-contract-ops
```

## Install a specific version

```bash
clawhub install telegram-contract-ops --version 1.0.0
```


path: references/deployment.md
content: # Telegram Contract Ops Deployment

## Mục tiêu

Cài skill này trên máy mới theo hướng:
1. install skill
2. nạp token/config local
3. start bot
4. test flow Plan B / Plan C

Skill chỉ đóng gói:
- logic
- scripts
- template `.docx`
- references

Skill **không** đóng gói:
- Telegram bot token
- Google auth/token
- chat IDs thực tế
- `.env*`
- `.state/*`
- logs

## Runtime requirements

### Bắt buộc
- Node.js
- Python 3

### Bắt buộc nếu dùng OCR hiện tại
- macOS
- Swift / Apple Vision runtime

## Portability note

OCR hiện tại của Plan C dùng Apple Vision qua Swift:
- chạy tốt trên macOS
- **không portable nguyên trạng sang Linux**

Nếu máy mới là Linux:
- Plan B vẫn chuyển tốt
- Telegram bot flow vẫn chuyển tốt
- Plan C OCR phải thay engine OCR khác

## Cài ClawHub CLI

```bash
npm i -g clawhub
```

## Install skill từ ClawHub

```bash
clawhub install telegram-contract-ops
```

Hoặc version cụ thể:

```bash
clawhub install telegram-contract-ops --version 1.0.0
```

## Install skill từ file local `.skill`

Nếu chưa publish lên ClawHub mà chỉ copy file package:
- copy `telegram-contract-ops.skill` sang máy mới
- giải nén/cài theo workflow nội bộ của bạn

Khuyến nghị vẫn là publish lên ClawHub để cài bằng `clawhub install`.

## Cấu hình local bắt buộc sau khi install

Tạo file env cục bộ, ví dụ `.env.telegram`:

```bash
TELEGRAM_BOT_TOKEN='YOUR_TELEGRAM_BOT_TOKEN'
TELEGRAM_POLL_INTERVAL_MS='3000'
PLAN_B_OUTPUT_DIR='/ABSOLUTE/PATH/TO/plan-b/output'
PLAN_B_TEMPLATE_DOCX='/ABSOLUTE/PATH/TO/talent_individual_original.docx'
```

Tạo file env group mapping, ví dụ `.env.telegram.groups`:

```bash
TELEGRAM_MANAGEMENT_CHAT_ID='-100xxxxxxxxxx'
TELEGRAM_CONTRACT_CHAT_ID='-100yyyyyyyyyy'
```

## Đường dẫn/asset cần kiểm tra

Sau khi install, xác nhận các file này tồn tại:
- original template `.docx`
- Telegram input template
- bot script
- Plan B generator
- Plan C OCR/parser scripts

## Start bot

Ví dụ:

```bash
cd /path/to/installed/skill-or-workspace
set -a
source ./.env.telegram
source ./.env.telegram.groups
set +a
node scripts/telegram-planb-bot.js
```

Nếu bạn copy scripts ra workspace runtime riêng, đổi path tương ứng.

## Telegram setup checklist

1. Tạo bot bằng BotFather
2. Lấy bot token
3. Disable Group Privacy nếu dùng group flows
4. Add bot vào các group cần dùng
5. Trong mỗi group, gửi `/id` để lấy `chat_id`
6. Ghi `chat_id` vào `.env.telegram.groups`

## Group routing hiện tại

### Management group
- role: Plan A / reminder / management reporting
- không dùng tạo hợp đồng

### Contract group
- role: Plan B + Plan C
- dùng các lệnh:
  - `/start`
  - `/id`
  - `/mauhopdong`
  - `/cccd`
  - `/cccd_debug`
  - `/status`
  - `/cancel`

## Test checklist sau khi cài trên máy mới

### Test 1 — bot sống
Trong Telegram:
```text
/start
```

### Test 2 — lấy mẫu hợp đồng
```text
/mauhopdong
```

### Test 3 — Plan B text input
Paste block hợp đồng mẫu và xác nhận bot trả file `.docx`

### Test 4 — Plan C eID OCR
```text
/cccd
```
Gửi 1 ảnh screenshot CCCD điện tử, kiểm tra bot trả block OCR

### Test 5 — OCR debug
```text
/cccd_debug
```
Gửi ảnh eID và xem raw OCR text

## Publish bằng ClawHub

### Login
```bash
clawhub login
clawhub whoami
```

### Publish
Chạy từ máy có thư mục skill:

```bash
clawhub publish ./skills/telegram-contract-ops --slug telegram-contract-ops --name "Telegram Contract Ops" --version 1.0.0 --changelog "Initial release: Plan B docx generation, Plan C eID OCR, Telegram bot workflow"
```

## Update trên máy khác

```bash
clawhub update telegram-contract-ops
```

hoặc:

```bash
clawhub update telegram-contract-ops --version 1.0.0
```

## Migration checklist

Khi chuyển sang máy mới, làm theo thứ tự:
1. Install Node.js, Python 3
2. Install ClawHub CLI
3. Install skill `telegram-contract-ops`
4. Tạo `.env.telegram`
5. Tạo `.env.telegram.groups`
6. Start bot
7. Test `/mauhopdong`
8. Test `/cccd`
9. Nếu là macOS, test OCR thật
10. Nếu là Linux, quyết định engine OCR thay thế cho Plan C


path: references/input-template.md
content: # Canonical Telegram Contract Input

```text
TEN:
NGAY_SINH:
CCCD:
NGAY_CAP:
NOI_CAP: CTCCS QLHC VTTXH
THUONG_TRU:
CHO_O_HIEN_TAI:
DIEN_THOAI:
EMAIL:
STK:
NGAN_HANG:
DU_AN:
CONG_VIEC:
TU_NGAY:
DEN_NGAY:
SO_TIEN:
SO_TIEN_CHU:
THANH_TOAN:
```

Use this as the standard internal input shape for Plan B.


path: references/macos.md
content: # Telegram Contract Ops on macOS

## Hỗ trợ hiện tại
- Plan B: supported
- Telegram bot runtime: supported
- Plan C OCR with Apple Vision: supported

## Runtime
- Node.js
- Python 3
- Swift / Apple Vision runtime

## OCR path
Current OCR implementation uses:
- `plan-c-ocr.swift`
- Apple Vision framework

## Test checklist
- `swift --version`
- `/mauhopdong`
- `/cccd`
- `/cccd_debug`

## Notes
macOS là môi trường tốt nhất hiện tại để chạy đủ Plan B + Plan C nguyên trạng.


path: references/troubleshooting.md
content: # Telegram Contract Ops Troubleshooting

## Bot không phản hồi
- kiểm tra bot process có đang chạy không
- kiểm tra token đúng chưa
- kiểm tra bot đã được add vào đúng group chưa
- kiểm tra đã disable Group Privacy trong BotFather chưa
- kiểm tra `TELEGRAM_CONTRACT_CHAT_ID` đúng chưa

## `/mauhopdong` không trả template
- kiểm tra chat hiện tại có đúng contract group không
- kiểm tra bot route theo `chat_id` đã đúng chưa

## Bot không gửi được `.docx`
- kiểm tra `PLAN_B_OUTPUT_DIR` có tồn tại và có quyền ghi
- kiểm tra template `.docx` path đúng chưa
- kiểm tra parser/generator có trả lỗi không

## OCR eID sai field
- dùng `/cccd_debug`
- xem raw OCR text
- tinh chỉnh parser theo text thực tế

## OCR không chạy
- trên macOS: kiểm tra `swift --version`
- trên Windows/Linux: OCR Apple Vision hiện không dùng nguyên trạng

## Chat/group sai chức năng
- dùng `/id` trong group để kiểm tra `chat_id`
- so lại với env group mapping


path: references/windows.md
content: # Telegram Contract Ops on Windows

## Hỗ trợ hiện tại
- Plan B: supported
- Telegram bot runtime: supported
- Plan C OCR Apple Vision: not supported natively

## Khuyến nghị
- dùng skill này trên Windows cho Plan B trước
- tạm disable OCR hoặc thay OCR engine riêng cho Windows

## Runtime
- Node.js
- Python 3
- PowerShell hoặc Command Prompt

## Cấu hình
- dùng `.env.telegram`
- dùng `.env.telegram.groups`
- đảm bảo path Windows tuyệt đối cho output/template
- xem file mẫu trong `assets/windows/`

## Chạy bot
Khuyên dùng PowerShell wrapper có sẵn trong `assets/windows/start-telegram-contract-bot.ps1`

## OCR note
Nếu cần Plan C trên Windows, thay OCR layer:
- Tesseract
- PaddleOCR
- OCR cloud API


path: scripts/plan-b-docx-generate.py
content: #!/usr/bin/env python3

from zipfile import ZipFile, ZIP_DEFLATED
from pathlib import Path
import json
import re
import shutil
import sys
import xml.etree.ElementTree as ET

NS = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'

WORKSPACE = Path('/Users/vtammm/.openclaw/workspace')
DEFAULT_INPUT = WORKSPACE / 'plan-b-sample-input.json'
DEFAULT_TEMPLATE = WORKSPACE / 'plan-b' / 'templates' / 'talent_individual_template_structured.docx'
DEFAULT_ORIGINAL = WORKSPACE / 'plan-b' / 'originals' / 'talent_individual_original.docx'
DEFAULT_OUTDIR = WORKSPACE / 'plan-b' / 'output'

PARA_REPLACEMENTS = {
    'Số: HĐKV- 070326 - FF - TÔ HOÀNG THY': 'Số: {{contract_no}}',
    'Hôm nay, ngày 07 tháng 03 năm 2026, tại Thành phố Hồ Chí Minh, chúng tôi gồm có:': 'Hôm nay, ngày {{sign_day}} tháng {{sign_month}} năm {{sign_year}}, tại {{sign_city}}, chúng tôi gồm có:',
    'Người đại diện theo pháp luật là: Ông NGUYỄN ANH TÚ': 'Người đại diện theo pháp luật là: {{party_a_representative_pronoun}} {{party_a_legal_representative}}',
    'Theo yêu cầu của bên A về việc thực hiện dự án “VLU ADMISSION 2026”, bên B đảm nhận và thực hiện công việc Diễn viên ghi hình trong dự án với các điều kiện sau:': 'Theo yêu cầu của bên A về việc thực hiện dự án “{{project_name}}”, bên B đảm nhận và thực hiện công việc {{job_title}} trong dự án với các điều kiện sau:',
    'Mục đích sử dụng hình ảnh diễn viên: Trên các phương tiện truyền thông kỹ thuật số dưới dạng video, không bao gồm screenshot hình ảnh từ phim quảng cáo để sử dụng trên các phương tiện truyền thông kỹ thuật số hay các phương tiện in ấn trong phạm vi lãnh thổ Việt Nam': 'Mục đích sử dụng hình ảnh diễn viên: {{usage_purpose}}',
    'Thời hạn sử dụng hình ảnh diễn viên: 1 năm không độc quyền kể từ ngày phát sóng đầu tiên': 'Thời hạn sử dụng hình ảnh diễn viên: {{usage_term}}',
    'Lãnh thổ sử dụng: Việt Nam': 'Lãnh thổ sử dụng: {{usage_territory}}',
    'Thời gian Hợp đồng này được thực hiện là 3 ngày, từ ngày 07/03/2026 đến 09/03/2026': 'Thời gian Hợp đồng này được thực hiện là {{duration_days}} ngày, từ ngày {{start_date}} đến {{end_date}}',
    'Tiền dịch vụ: Thực hiện công việc tại Điều 1 là: 3.000.000 đồng/dự án': 'Tiền dịch vụ: Thực hiện công việc tại Điều 1 là: {{service_fee}} đồng/dự án',
    '(Bằng chữ: Ba triệu đồng), chưa bao gồm tiền thuế thu nhập cá nhân': '(Bằng chữ: {{service_fee_text}}), {{tax_note}}',
    'Phương thức thanh toán: Chuyển khoản': 'Phương thức thanh toán: {{payment_method}}',
    'Bên A thanh toán cho Bên B 100% giá trị hợp đồng tương đương: 3.000.000 đồng (Bằng chữ: Ba triệu đồng), sau khi hai bên ký kết hợp đồng, trong vòng 45 ngày làm việc sau khi hai bên hoàn thành công việc, Bên B bên bàn giao thành phẩm hoàn chỉnh cho Bên A.': '{{payment_terms}}',
}

FIELD_RUN_REPLACEMENTS = {
    'Tên tổ chức: CÔNG TY TNHH FLEX FILMS': ('party_a_company_name', 2),
    'Địa chỉ trụ sở: Số 79 Đường 30, Phường Tân Hưng, Thành phố Hồ Chí Minh': ('party_a_address', 3),
    'Mã số doanh nghiệp: 0315637917': ('party_a_tax_code', 2),
    'Chức vụ: Tổng Giám Đốc': ('party_a_title', 2),
    'Họ và tên: TÔ HOÀNG THY': ('party_b_name', 3),
    'Ngày tháng năm sinh:\xa0 06/04/2004': ('party_b_dob', 2),
    'Căn cước công dân số:\xa0079304002466, ngày cấp 07/04/2021,\xa0 nơi cấp: CTCCS QLHC VTTXH': ('party_b_id_full', 2),
    'Nơi thường trú: 63/7A TL29, KP3C Thạnh Lộc, Quận 12, Thành phố Hồ Chí Minh': ('party_b_permanent_address', 2),
    'Chỗ ở hiện tại:\xa044 Võ Thị Thừa, Phường An Phú Đông, Thành phố Hồ Chí Minh': ('party_b_current_address', 2),
    'Điện thoại:\xa00909898773': ('party_b_phone', 2),
    'Email : [REDACTED_SECRET]': ('party_b_email', 2),
    'Số TK ngân hàng: 77706042004': ('party_b_bank_account', 2),
    'Tên ngân hàng: Ngân hàng thương mại cổ phần Kỹ Thương Việt Nam (Techcombank)': ('party_b_bank_name', 3),
    'NGUYỄN ANH TÚ                                                                          TÔ HOÀNG THY': ('signature_names', None),
}


def load_json(path: Path):
    return json.loads(path.read_text(encoding='utf-8'))


def ensure_dir(path: Path):
    path.mkdir(parents=True, exist_ok=True)


def slugify(s: str) -> str:
    import unicodedata
    s = unicodedata.normalize('NFD', s)
    s = ''.join(ch for ch in s if unicodedata.category(ch) != 'Mn')
    s = s.replace('đ', 'd').replace('Đ', 'D')
    s = re.sub(r'[^A-Za-z0-9]+', '-', s).strip('-').lower()
    return s or 'output'


def paragraph_text(p):
    return ''.join((t.text or '') for t in p.findall('.//w:t', NS)).strip()


def set_paragraph_text_keep_style(p, new_text):
    texts = p.findall('.//w:t', NS)
    if not texts:
        r = ET.SubElement(p, W + 'r')
        t = ET.SubElement(r, W + 't')
        t.text = new_text
        return
    texts[0].text = new_text
    for t in texts[1:]:
        t.text = ''


def set_value_runs(p, start_index, new_text):
    texts = p.findall('.//w:t', NS)
    if not texts:
        set_paragraph_text_keep_style(p, new_text)
        return
    if start_index >= len(texts):
        texts[-1].text = new_text
        return
    texts[start_index].text = new_text
    for t in texts[start_index + 1:]:
        t.text = ''


def set_signature_runs(p, left_text, right_text):
    texts = p.findall('.//w:t', NS)
    if len(texts) >= 5:
        texts[0].text = '           ' + left_text
        texts[1].text = '                                           '
        texts[2].text = '                     '
        texts[3].text = '   '
        texts[4].text = right_text
    else:
        set_paragraph_text_keep_style(p, f'{left_text}                                                                          {right_text}')


def build_structured_template(src: Path, out: Path):
    tmp = out.parent / '_tmp_structured'
    if tmp.exists():
        shutil.rmtree(tmp)
    ensure_dir(tmp)
    with ZipFile(src) as z:
        z.extractall(tmp)
    doc = tmp / 'word' / 'document.xml'
    tree = ET.parse(doc)
    root = tree.getroot()
    for p in root.findall('.//w:p', NS):
        txt = paragraph_text(p)
        if txt in PARA_REPLACEMENTS:
            set_paragraph_text_keep_style(p, PARA_REPLACEMENTS[txt])
    tree.write(doc, encoding='utf-8', xml_declaration=True)
    with ZipFile(out, 'w', ZIP_DEFLATED) as z:
        for f in tmp.rglob('*'):
            if f.is_file():
                z.write(f, f.relative_to(tmp))
    shutil.rmtree(tmp)


def render_string(s: str, data: dict):
    def repl(m):
        key = m.group(1)
        return str(data.get(key, m.group(0)))
    return re.sub(r'\{\{\s*([A-Za-z0-9_]+)\s*\}\}', repl, s)


def render_docx(template: Path, data: dict, out: Path):
    tmp = out.parent / '_tmp_render'
    if tmp.exists():
        shutil.rmtree(tmp)
    ensure_dir(tmp)
    with ZipFile(template) as z:
        z.extractall(tmp)
    doc = tmp / 'word' / 'document.xml'
    tree = ET.parse(doc)
    root = tree.getroot()
    unresolved = set()

    enriched = dict(data)
    enriched['party_a_representative_full'] = f"{data.get('party_a_representative_pronoun', '')} {data.get('party_a_legal_representative', '')}".strip()
    enriched['party_b_id_full'] = f"{data.get('party_b_id_number', '')}, ngày cấp {data.get('party_b_id_issue_date', '')}, nơi cấp: {data.get('party_b_id_issue_place', '')}".strip()

    for p in root.findall('.//w:p', NS):
        txt = paragraph_text(p)
        if txt in FIELD_RUN_REPLACEMENTS:
            field_name, start_index = FIELD_RUN_REPLACEMENTS[txt]
            if field_name == 'signature_names':
                set_signature_runs(p, data.get('party_a_sign_name', ''), data.get('party_b_sign_name', ''))
                continue
            set_value_runs(p, start_index, str(enriched.get(field_name, '')))
            continue
        if txt in PARA_REPLACEMENTS:
            rendered = render_string(PARA_REPLACEMENTS[txt], data)
            leftovers = re.findall(r'\{\{\s*([A-Za-z0-9_]+)\s*\}\}', rendered)
            unresolved.update(leftovers)
            set_paragraph_text_keep_style(p, rendered)
            continue
        if '{{' in txt and '}}' in txt:
            rendered = render_string(txt, data)
            leftovers = re.findall(r'\{\{\s*([A-Za-z0-9_]+)\s*\}\}', rendered)
            unresolved.update(leftovers)
            set_paragraph_text_keep_style(p, rendered)
    tree.write(doc, encoding='utf-8', xml_declaration=True)
    with ZipFile(out, 'w', ZIP_DEFLATED) as z:
        for f in tmp.rglob('*'):
            if f.is_file():
                z.write(f, f.relative_to(tmp))
    shutil.rmtree(tmp)
    return sorted(unresolved)


def main():
    mode = sys.argv[1] if len(sys.argv) > 1 else 'generate'
    if mode == 'build-template':
        out = DEFAULT_TEMPLATE
        ensure_dir(out.parent)
        build_structured_template(DEFAULT_ORIGINAL, out)
        print(json.dumps({'ok': True, 'template': str(out)}, ensure_ascii=False, indent=2))
        return

    input_path = Path(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_INPUT
    template_path = Path(sys.argv[3]) if len(sys.argv) > 3 else DEFAULT_TEMPLATE
    out_dir = Path(sys.argv[4]) if len(sys.argv) > 4 else DEFAULT_OUTDIR
    ensure_dir(out_dir)
    if not template_path.exists():
        build_structured_template(DEFAULT_ORIGINAL, template_path)
    data = load_json(input_path)
    base = f"{slugify(data.get('contract_no','contract'))}__{slugify(data.get('party_b_name','unknown'))}"
    out = out_dir / f'{base}.structured.docx'
    unresolved = render_docx(template_path, data, out)
    print(json.dumps({'ok': True, 'input': str(input_path), 'template': str(template_path), 'output': str(out), 'unresolved': unresolved}, ensure_ascii=False, indent=2))


if __name__ == '__main__':
    main()


path: scripts/plan-b-telegram-to-docx.js
content: #!/usr/bin/env node

const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');

const REQUIRED_KEYS = [
  'TEN', 'NGAY_SINH', 'CCCD', 'NGAY_CAP', 'NOI_CAP', 'THUONG_TRU', 'CHO_O_HIEN_TAI',
  'DIEN_THOAI', 'EMAIL', 'STK', 'NGAN_HANG', 'DU_AN', 'CONG_VIEC', 'TU_NGAY',
  'DEN_NGAY', 'SO_TIEN', 'SO_TIEN_CHU', 'THANH_TOAN'
];

const KEY_MAP = {
  TEN: 'party_b_name',
  NGAY_SINH: 'party_b_dob',
  CCCD: 'party_b_id_number',
  NGAY_CAP: 'party_b_id_issue_date',
  NOI_CAP: 'party_b_id_issue_place',
  THUONG_TRU: 'party_b_permanent_address',
  CHO_O_HIEN_TAI: 'party_b_current_address',
  DIEN_THOAI: 'party_b_phone',
  EMAIL: 'party_b_email',
  STK: 'party_b_bank_account',
  NGAN_HANG: 'party_b_bank_name',
  DU_AN: 'project_name',
  CONG_VIEC: 'job_title',
  TU_NGAY: 'start_date',
  DEN_NGAY: 'end_date',
  SO_TIEN: 'service_fee',
  SO_TIEN_CHU: 'service_fee_text',
  THANH_TOAN: 'payment_terms',
};

const DEFAULTS = {
  contract_title: 'HỢP ĐỒNG KHOÁN VIỆC',
  sign_city: 'Thành phố Hồ Chí Minh',
  payment_method: 'Chuyển khoản',
  tax_note: 'chưa bao gồm tiền thuế thu nhập cá nhân',
  usage_territory: 'Việt Nam',
  usage_term: '1 năm không độc quyền kể từ ngày phát hành đầu tiên',
  usage_purpose: 'Sử dụng hình ảnh và video trên các nền tảng truyền thông kỹ thuật số của dự án trong phạm vi đã được phê duyệt.',
  party_a_company_name: 'CÔNG TY TNHH FLEX FILMS',
  party_a_address: 'Số 79 Đường 30, Phường Tân Hưng, Thành phố Hồ Chí Minh',
  party_a_tax_code: '0315637917',
  party_a_legal_representative: 'NGUYỄN ANH TÚ',
  party_a_representative_pronoun: 'Ông',
  party_a_title: 'Tổng Giám Đốc',
  party_a_sign_name: 'NGUYỄN ANH TÚ',
};

function parseDate(s) {
  const m = String(s || '').trim().match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
  if (!m) return null;
  const [_, dd, mm, yyyy] = m;
  const dt = new Date(Number(yyyy), Number(mm) - 1, Number(dd));
  if (dt.getFullYear() !== Number(yyyy) || dt.getMonth() !== Number(mm) - 1 || dt.getDate() !== Number(dd)) return null;
  return dt;
}

function daysBetween(a, b) {
  const ua = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
  const ub = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
  return Math.round((ub - ua) / 86400000);
}

function slugify(input) {
  return String(input || '')
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .replace(/đ/g, 'd').replace(/Đ/g, 'D')
    .replace(/[^a-zA-Z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .toUpperCase();
}

function buildContractNo(data, now) {
  const dd = String(now.getDate()).padStart(2, '0');
  const mm = String(now.getMonth() + 1).padStart(2, '0');
  const yy = String(now.getFullYear()).slice(-2);
  return `HĐKV-${dd}${mm}${yy}-FF-${slugify(data.TEN)}`;
}

function parseTemplateInput(text) {
  const lines = String(text || '').split(/\r?\n/);
  const parsed = {};
  for (const raw of lines) {
    const line = raw.trim();
    if (!line) continue;
    const idx = line.indexOf(':');
    if (idx === -1) continue;
    const key = line.slice(0, idx).trim().toUpperCase();
    const value = line.slice(idx + 1).trim();
    if (REQUIRED_KEYS.includes(key)) parsed[key] = value;
  }
  return parsed;
}

function validate(parsed) {
  const missingFields = REQUIRED_KEYS.filter((k) => !parsed[k]);
  const invalidFields = [];
  for (const field of ['NGAY_SINH', 'NGAY_CAP', 'TU_NGAY', 'DEN_NGAY']) {
    if (parsed[field] && !parseDate(parsed[field])) invalidFields.push({ field, reason: 'invalid_date_format' });
  }
  if (parsed.EMAIL && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(parsed.EMAIL)) invalidFields.push({ field: 'EMAIL', reason: 'invalid_email' });
  if (parsed.TU_NGAY && parsed.DEN_NGAY) {
    const s = parseDate(parsed.TU_NGAY);
    const e = parseDate(parsed.DEN_NGAY);
    if (s && e && e < s) invalidFields.push({ field: 'DEN_NGAY', reason: 'end_before_start' });
  }
  return { missingFields, invalidFields, ok: missingFields.length === 0 && invalidFields.length === 0 };
}

function mapToPlanB(parsed) {
  const now = new Date();
  const start = parseDate(parsed.TU_NGAY);
  const end = parseDate(parsed.DEN_NGAY);
  const durationDays = start && end ? String(daysBetween(start, end) + 1) : '';
  const mapped = { ...DEFAULTS };
  for (const [src, dest] of Object.entries(KEY_MAP)) mapped[dest] = parsed[src] || '';
  mapped.contract_no = buildContractNo(parsed, now);
  mapped.sign_day = String(now.getDate()).padStart(2, '0');
  mapped.sign_month = String(now.getMonth() + 1).padStart(2, '0');
  mapped.sign_year = String(now.getFullYear());
  mapped.duration_days = durationDays;
  mapped.party_b_sign_name = parsed.TEN || '';
  mapped.currency = 'VND';
  mapped.party_b_role_category = 'talent_individual';
  mapped.contract_status = 'draft';
  mapped.source_channel = 'telegram';
  mapped.created_by = 'FlexClawBot';
  mapped.created_at = now.toISOString();
  return mapped;
}

function main() {
  const inputPath = process.argv[2] || 'plan-b-telegram-input-sample.txt';
  const outputDir = process.argv[3] || path.join('plan-b', 'output');
  const templatePath = process.argv[4] || path.join('plan-b', 'originals', 'talent_individual_original.docx');

  const rawText = fs.readFileSync(path.resolve(inputPath), 'utf8');
  const parsed = parseTemplateInput(rawText);
  const validation = validate(parsed);

  if (!validation.ok) {
    console.error(JSON.stringify({ ok: false, ...validation }, null, 2));
    process.exit(1);
  }

  const mapped = mapToPlanB(parsed);
  fs.mkdirSync(path.resolve(outputDir), { recursive: true });
  const tempJson = path.resolve(outputDir, '.plan-b-telegram-mapped.json');
  fs.writeFileSync(tempJson, JSON.stringify(mapped, null, 2));

  const out = execFileSync('python3', [
    'plan-b-docx-generate.py',
    'generate',
    tempJson,
    templatePath,
    outputDir,
  ], { encoding: 'utf8' });

  const payload = JSON.parse(out);
  console.log(JSON.stringify({
    ok: true,
    inputPath: path.resolve(inputPath),
    parsed,
    mappedJson: tempJson,
    generated: payload.output,
  }, null, 2));
}

main();


path: scripts/plan-c-eid-parse.js
content: #!/usr/bin/env node

const fs = require('fs');

function clean(s) {
  return String(s || '')
    .replace(/\u00a0/g, ' ')
    .replace(/[ \t]+/g, ' ')
    .trim();
}

function linesFromText(text) {
  return String(text || '')
    .split(/\r?\n/)
    .map(clean)
    .filter(Boolean);
}

function isJunkValue(value, labels = []) {
  const v = clean(value);
  if (!v) return true;
  if (v === ':' || v === '：') return true;
  if (/^[::\-\s]+$/.test(v)) return true;
  if (labels.some(l => new RegExp('^' + l + '$', 'i').test(v))) return true;
  return false;
}

function getValueAfterLabel(text, labels) {
  for (const label of labels) {
    const re = new RegExp(label + '\\s*:?\\s*(.+)', 'i');
    const m = text.match(re);
    if (m && m[1]) {
      const val = clean(m[1]);
      if (!isJunkValue(val, labels)) return val;
    }
  }
  return '';
}

function nextUsefulValue(lines, startIndex, labels) {
  for (let j = startIndex + 1; j < Math.min(lines.length, startIndex + 4); j++) {
    const candidate = clean(lines[j]);
    if (!isJunkValue(candidate, labels)) return candidate;
  }
  return '';
}

function isLikelyLabel(line) {
  return /Họ và tên|Ho va ten|Full name|Ngày sinh|Ngay sinh|Date of birth|Số định danh cá nhân|Personal Identification number|Nơi thường trú|Noi thuong tru|Place of residence|Nơi ở hiện tại|Noi o hien tai|Current address|Ngày cấp|Ngay cap|Date of issue|Quê quán|Que quan|Place of origin|Giới tính|Gioi tinh|Sex|Quốc tịch|Quoc tich|Nationality/i.test(line);
}

function collectValueBlock(lines, startIndex, labels, maxLines = 3) {
  const parts = [];
  for (let j = startIndex + 1; j < Math.min(lines.length, startIndex + 1 + maxLines); j++) {
    const candidate = clean(lines[j]);
    if (!candidate) continue;
    if (isJunkValue(candidate, labels)) continue;
    if (isLikelyLabel(candidate)) break;
    parts.push(candidate);
  }
  return clean(parts.join(' '));
}

function getField(lines, labels) {
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    const direct = getValueAfterLabel(line, labels);
    if (direct) return direct;
    if (labels.some(l => new RegExp('^' + l + '$', 'i').test(line))) {
      const next = nextUsefulValue(lines, i, labels);
      if (next) return next;
    }
  }
  return '';
}

function getFieldBlock(lines, labels, maxLines = 3) {
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    const direct = getValueAfterLabel(line, labels);
    if (direct && !isLikelyLabel(direct)) return direct;
    if (labels.some(l => new RegExp('^' + l + '$', 'i').test(line) || new RegExp(l, 'i').test(line))) {
      const block = collectValueBlock(lines, i, labels, maxLines);
      if (block) return block;
    }
  }
  return '';
}

function pickLikelyNameLine(lines) {
  const blacklist = [
    /CỘNG HÒA|Độc lập|Căn cước|Số định danh|Personal Identification|Ngày sinh|Date of birth|Quê quán|Place of origin|Nơi thường trú|Place of residence|Nơi ở hiện tại|Current address|Ngày cấp|Date of issue|Giới tính|Nationality|Quốc tịch/i
  ];
  for (const line of lines) {
    if (blacklist.some(re => re.test(line))) continue;
    const upper = line.toUpperCase();
    if (/^[A-ZÀ-ỸĐ\s]{6,60}$/.test(upper) && upper.split(/\s+/).length >= 2) {
      return clean(line);
    }
  }
  return '';
}

function isAddressLike(line) {
  const s = clean(line);
  if (!s) return false;
  if (isLikelyLabel(s)) return false;
  return /(Ấp|Ap|Xã|Xa|Phường|Phuong|Quận|Quan|Huyện|Huyen|Tỉnh|Tinh|Thành phố|Thanh pho|Thị trấn|Thi tran|KP|Khu phố|Đường|Duong|P\.|Q\.)/i.test(s);
}

function pickAddressAfterLabel(lines, labels) {
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (labels.some(l => new RegExp(l, 'i').test(line))) {
      const parts = [];
      for (let j = i + 1; j < Math.min(lines.length, i + 5); j++) {
        const candidate = clean(lines[j]);
        if (!candidate) continue;
        if (isLikelyLabel(candidate)) break;
        if (isAddressLike(candidate) || parts.length > 0) parts.push(candidate);
      }
      const merged = clean(parts.join(' '));
      if (merged) return merged;
    }
  }
  return '';
}

function findDateNear(lines, labels) {
  const dateRe = /(\d{2}\/\d{2}\/\d{2,4})/;
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (labels.some(l => new RegExp(l, 'i').test(line))) {
      const same = line.match(dateRe);
      if (same) return same[1];
      for (let j = i + 1; j < Math.min(lines.length, i + 4); j++) {
        const next = (lines[j] || '').match(dateRe);
        if (next) return next[1];
      }
    }
  }
  return '';
}

function findCccd(lines) {
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (/Số định danh cá nhân|Personal Identification number|\bSố\/?No\b|\bNo\b/i.test(line)) {
      const direct = line.replace(/\s+/g, '').match(/(\d{12})/);
      if (direct) return direct[1];
      for (let j = i + 1; j < Math.min(lines.length, i + 4); j++) {
        const candidate = lines[j].replace(/\s+/g, '').match(/(\d{12})/);
        if (candidate) return candidate[1];
      }
    }
  }
  for (const line of lines) {
    const squashed = line.replace(/\s+/g, '');
    const m = squashed.match(/(\d{12})/);
    if (m) return m[1];
  }
  return '';
}

function toBlock(fields) {
  return [
    `TEN: ${fields.TEN || ''}`,
    `NGAY_SINH: ${fields.NGAY_SINH || ''}`,
    `CCCD: ${fields.CCCD || ''}`,
    `NGAY_CAP: ${fields.NGAY_CAP || ''}`,
    `NOI_CAP: CTCCS QLHC VTTXH`,
    `THUONG_TRU: ${fields.THUONG_TRU || ''}`,
    `CHO_O_HIEN_TAI: ${fields.CHO_O_HIEN_TAI || ''}`,
    'DIEN_THOAI: ',
    'EMAIL: ',
    'STK: ',
    'NGAN_HANG: ',
    'DU_AN: ',
    'CONG_VIEC: ',
    'TU_NGAY: ',
    'DEN_NGAY: ',
    'SO_TIEN: ',
    'SO_TIEN_CHU: ',
    'THANH_TOAN: '
  ].join('\n');
}

function cleanAddressTail(value) {
  return clean(String(value || '')
    .replace(/\s+(Nơi tạm trú|Noi tam tru|Nơi ở hiện tại|Noi o hien tai|Current address|Đặc điểm nhân dạng|Dac diem nhan dang|Đặc điểm nhận dạng|Ngày cấp|Ngay cap|Date of issue|Nơi cấp|Noi cap).*$/i, '')
    .replace(/\s+/g, ' ')
    .trim());
}

function parseEid(text) {
  const lines = linesFromText(text)
    .filter(line => !/CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM|Độc lập - Tự do - Hạnh phúc/i.test(line));

  let TEN = getFieldBlock(lines, ['Họ và tên', 'Ho va ten', 'Full name'], 2) || getField(lines, ['Họ và tên', 'Ho va ten', 'Full name']);
  if (!TEN) TEN = pickLikelyNameLine(lines);
  if (TEN) TEN = TEN.toUpperCase();

  const NGAY_SINH = findDateNear(lines, ['Ngày sinh', 'Ngay sinh', 'Date of birth']);
  const CCCD = findCccd(lines);
  let THUONG_TRU = getFieldBlock(lines, ['Nơi thường trú', 'Noi thuong tru', 'Place of residence'], 4) || getField(lines, ['Nơi thường trú', 'Noi thuong tru', 'Place of residence']);
  if (!THUONG_TRU) THUONG_TRU = pickAddressAfterLabel(lines, ['Nơi thường trú', 'Noi thuong tru', 'Place of residence']);
  THUONG_TRU = cleanAddressTail(THUONG_TRU);

  let CHO_O_HIEN_TAI = getFieldBlock(lines, ['Nơi ở hiện tại', 'Noi o hien tai', 'Current address'], 4) || getField(lines, ['Nơi ở hiện tại', 'Noi o hien tai', 'Current address']);
  if (!CHO_O_HIEN_TAI) CHO_O_HIEN_TAI = pickAddressAfterLabel(lines, ['Nơi ở hiện tại', 'Noi o hien tai', 'Current address']);
  CHO_O_HIEN_TAI = cleanAddressTail(CHO_O_HIEN_TAI);

  const NGAY_CAP = findDateNear(lines, ['Ngày cấp', 'Ngay cap', 'Ngày cấp Căn cước công dân gần nhất', 'Date of issue']);

  return { TEN, NGAY_SINH, CCCD, NGAY_CAP, THUONG_TRU, CHO_O_HIEN_TAI };
}

function main() {
  const input = process.argv[2];
  if (!input) {
    console.error('Usage: node plan-c-eid-parse.js <ocr-json-file>');
    process.exit(1);
  }
  const data = JSON.parse(fs.readFileSync(input, 'utf8'));
  const text = data.joinedText || (data.lines || []).map(x => x.text).join('\n');
  const fields = parseEid(text);
  console.log(JSON.stringify({ ok: true, version: 'eid-v1', fields, block: toBlock(fields), sourceText: text }, null, 2));
}

main();


path: scripts/plan-c-ocr.swift
content: #!/usr/bin/env swift

import Foundation
import Vision
import AppKit

struct OCRLine: Codable {
    let text: String
    let confidence: Float
}

struct OCROutput: Codable {
    let ok: Bool
    let file: String
    let lines: [OCRLine]
    let joinedText: String
}

func fail(_ msg: String) -> Never {
    fputs(msg + "\n", stderr)
    exit(1)
}

let args = CommandLine.arguments
guard args.count >= 2 else {
    fail("Usage: plan-c-ocr.swift <image-path>")
}

let imagePath = NSString(string: args[1]).expandingTildeInPath
let imageURL = URL(fileURLWithPath: imagePath)

guard let image = NSImage(contentsOf: imageURL) else {
    fail("Cannot load image: \(imagePath)")
}

guard let tiffData = image.tiffRepresentation,
      let bitmap = NSBitmapImageRep(data: tiffData),
      let cgImage = bitmap.cgImage else {
    fail("Cannot create CGImage from: \(imagePath)")
}

let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
request.recognitionLanguages = ["vi-VN", "en-US"]

let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])

do {
    try handler.perform([request])
} catch {
    fail("Vision OCR failed: \(error)")
}

guard let observations = request.results else {
    fail("No OCR results")
}

let lines: [OCRLine] = observations.compactMap { obs in
    guard let top = obs.topCandidates(1).first else { return nil }
    return OCRLine(text: top.string, confidence: top.confidence)
}

let output = OCROutput(
    ok: true,
    file: imagePath,
    lines: lines,
    joinedText: lines.map { $0.text }.joined(separator: "\n")
)

let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
let data = try encoder.encode(output)
FileHandle.standardOutput.write(data)


path: scripts/telegram-planb-bot.js
content: #!/usr/bin/env node

const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');

const [REDACTED_SECRET] || '';
const API = TOKEN ? `https://api.telegram.org/bot${TOKEN}` : '';
const POLL_MS = Number(process.env.TELEGRAM_POLL_INTERVAL_MS || 3000);
const WORKSPACE = process.cwd();
const OUTPUT_DIR = process.env.PLAN_B_OUTPUT_DIR || path.join(WORKSPACE, 'plan-b', 'output');
const TEMPLATE_DOCX = process.env.PLAN_B_TEMPLATE_DOCX || path.join(WORKSPACE, 'plan-b', 'originals', 'talent_individual_original.docx');
const STATE_FILE = path.join(WORKSPACE, '.state', 'telegram-planb-bot.json');
const MANAGEMENT_CHAT_ID = String(process.env.TELEGRAM_MANAGEMENT_CHAT_ID || '');
const CONTRACT_CHAT_ID = String(process.env.TELEGRAM_CONTRACT_CHAT_ID || '');
const PLAN_C_TMP_DIR = path.join(WORKSPACE, '.state', 'plan-c');
const PLAN_C_MODE = 'eid';

function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); }
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function loadState() {
  try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); }
  catch { return { offset: 0, sessions: {} }; }
}
function saveState(s) { ensureDir(path.dirname(STATE_FILE)); fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2)); }

async function tg(method, payload) {
  const res = await fetch(`${API}/${method}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload || {}),
  });
  const json = await res.json();
  if (!json.ok) throw new Error(`${method} failed: ${JSON.stringify(json)}`);
  return json.result;
}

async function sendMessage(chatId, text, replyToMessageId) {
  return tg('sendMessage', { chat_id: chatId, text, reply_to_message_id: replyToMessageId });
}

async function sendDocument(chatId, filePath, caption, replyToMessageId) {
  const form = new FormData();
  form.append('chat_id', String(chatId));
  if (caption) form.append('caption', caption);
  if (replyToMessageId) form.append('reply_to_message_id', String(replyToMessageId));
  form.append('document', new Blob([fs.readFileSync(filePath)]), path.basename(filePath));
  const res = await fetch(`${API}/sendDocument`, { method: 'POST', body: form });
  const json = await res.json();
  if (!json.ok) throw new Error(`sendDocument failed: ${JSON.stringify(json)}`);
  return json.result;
}

async function getFile(fileId) {
  return tg('getFile', { file_id: fileId });
}

async function downloadTelegramFile(fileId, destPath) {
  const info = await getFile(fileId);
  const res = await fetch(`https://api.telegram.org/file/bot${TOKEN}/${info.file_path}`);
  if (!res.ok) throw new Error(`download file failed: ${res.status}`);
  const buf = Buffer.from(await res.arrayBuffer());
  ensureDir(path.dirname(destPath));
  fs.writeFileSync(destPath, buf);
  return destPath;
}

function looksLikeContractInput(text) {
  const mustHave = ['TEN:', 'NGAY_SINH:', 'CCCD:', 'DU_AN:', 'CONG_VIEC:', 'SO_TIEN:'];
  const upper = String(text || '').toUpperCase();
  return mustHave.every(k => upper.includes(k));
}

function runCommand(cmd, args) {
  return execFileSync(cmd, args, { cwd: WORKSPACE, encoding: 'utf8' });
}

function sessionKey(chatId, userId) {
  return `${chatId}:${userId}`;
}

function getSession(state, chatId, userId) {
  return state.sessions?.[sessionKey(chatId, userId)] || null;
}

function setSession(state, chatId, userId, session) {
  state.sessions = state.sessions || {};
  state.sessions[sessionKey(chatId, userId)] = session;
}

function clearSession(state, chatId, userId) {
  if (state.sessions) delete state.sessions[sessionKey(chatId, userId)];
}

function isPhotoMessage(msg) {
  return Array.isArray(msg.photo) && msg.photo.length > 0;
}

function getBestPhotoFileId(msg) {
  if (!isPhotoMessage(msg)) return null;
  const sorted = [...msg.photo].sort((a, b) => (b.file_size || 0) - (a.file_size || 0));
  return sorted[0]?.file_id || null;
}

function runJsonCommand(cmd, args) {
  const out = execFileSync(cmd, args, { cwd: WORKSPACE, encoding: 'utf8' });
  return JSON.parse(out);
}

function buildCccdDraftBlock() {
  return `TEN:
NGAY_SINH:
CCCD:
NGAY_CAP:
NOI_CAP: CTCCS QLHC VTTXH
THUONG_TRU:
CHO_O_HIEN_TAI:
DIEN_THOAI:
EMAIL:
STK:
NGAN_HANG:
DU_AN:
CONG_VIEC:
TU_NGAY:
DEN_NGAY:
SO_TIEN:
SO_TIEN_CHU:
THANH_TOAN:`;
}

function scoreFrontBack(text) {
  const t = String(text || '');
  const squashed = t.replace(/\s+/g, '');
  let front = 0;
  let back = 0;

  // Front-side indicators user explicitly confirmed
  if (/Số\/?No\.?|So\/?No\.?|\bNo\.?\b/i.test(t)) front += 2;
  if (/Họ và tên|Ho va ten|Full name/i.test(t)) front += 4;
  if (/Ngày sinh|Ngay sinh|Date of birth/i.test(t)) front += 4;
  if (/Quê quán|Que quan|Place of origin/i.test(t)) front += 3;
  if (/Nơi thường trú|Noi thuong tru|Place of residence/i.test(t)) front += 3;
  if (/Có giá trị đến|Co gia tri den|Date of expiry/i.test(t)) front += 4;
  if (/\b\d{2}\/\d{2}\/\d{2,4}\b/.test(t)) front += 1;
  if (/\b\d{12}\b/.test(squashed)) front += 4;
  if (/^[A-ZÀ-ỸĐ\s]{6,40}$/m.test(t)) front += 1; // uppercase full-name style line

  // Ignore sex/nationality as extraction targets, but presence still hints front layout slightly
  if (/Giới tính|Gioi tinh|Sex|Quốc tịch|Quoc tich|Nationality/i.test(t)) front += 1;

  // Back-side indicators
  if (/Nơi cấp|Noi cap|Place of issue/i.test(t)) back += 4;
  if (/Ngày cấp|Ngay cap|Date of issue/i.test(t)) back += 4;
  if (/Đặc điểm nhận dạng|Dac diem nhan dang|Personal identification/i.test(t)) back += 3;
  if (/IDVNM|<</i.test(t)) back += 5;
  if (/MRZ/i.test(t)) back += 2;

  return { front, back };
}

async function runSingleOcr(imagePath) {
  const jsonPath = `${imagePath}.ocr.json`;
  fs.writeFileSync(jsonPath, execFileSync('swift', ['plan-c-ocr.swift', imagePath], { cwd: WORKSPACE, encoding: 'utf8' }));
  const data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
  return { data, jsonPath };
}

function buildOcrDebugText(ocrData) {
  const lines = (ocrData.lines || []).map((x, i) => `${i + 1}. ${x.text} [${Number(x.confidence || 0).toFixed(2)}]`);
  const joined = lines.join('\n');
  return joined.length > 3500 ? joined.slice(0, 3500) + '\n...[truncated]' : joined;
}

async function runEidOcr(imagePath) {
  const result = await runSingleOcr(imagePath);
  const parsed = runJsonCommand('node', ['plan-c-eid-parse.js', result.jsonPath]);
  return { parsed, rawOcr: result.data, artifacts: [result.jsonPath] };
}

async function handleContractFlow(msg, state) {
  const chatId = msg.chat.id;
  const userId = msg.from?.id;
  const text = (msg.text || '').trim();
  const messageId = msg.message_id;
  const normalizedCommand = text.split(/\s+/)[0].split('@')[0];
  const session = getSession(state, chatId, userId);

  if (normalizedCommand === '/cancel') {
    clearSession(state, chatId, userId);
    saveState(state);
    await sendMessage(chatId, 'Đã hủy phiên hiện tại.', messageId);
    return;
  }

  if (normalizedCommand === '/status') {
    if (!session) {
      await sendMessage(chatId, 'Hiện không có session nào đang chạy.', messageId);
      return;
    }
    await sendMessage(chatId, `Trạng thái hiện tại: ${session.state}`, messageId);
    return;
  }

  if (normalizedCommand === '/mauhopdong') {
    const template = fs.readFileSync(path.join(WORKSPACE, 'plan-b-telegram-template.txt'), 'utf8');
    await sendMessage(chatId, template, messageId);
    return;
  }

  if (normalizedCommand === '/cccd' || normalizedCommand === '/cccd_debug') {
    setSession(state, chatId, userId, {
      state: 'WAITING_EID_IMAGE',
      startedAt: new Date().toISOString(),
      eidReceived: false,
      debugOcr: normalizedCommand === '/cccd_debug',
    });
    saveState(state);
    await sendMessage(chatId,
`Bắt đầu quy trình đọc CCCD điện tử.

Hãy gửi 1 ảnh chụp màn hình CCCD điện tử rõ nét từ app.

Sau khi nhận ảnh, mình sẽ OCR và trả block thông tin để bạn xác nhận trước khi tạo hợp đồng.${normalizedCommand === '/cccd_debug' ? '\n\nChế độ debug đang bật: mình sẽ trả thêm raw OCR text.' : ''}`,
    messageId);
    return;
  }

  if (session && session.state === 'WAITING_EID_IMAGE') {
    if (!isPhotoMessage(msg)) {
      await sendMessage(chatId, 'Hiện tại mình đang chờ 1 ảnh chụp màn hình CCCD điện tử từ app.', messageId);
      return;
    }

    const fileId = getBestPhotoFileId(msg);
    if (!fileId) {
      await sendMessage(chatId, 'Mình không lấy được file ảnh từ Telegram. Hãy thử gửi lại ảnh.', messageId);
      return;
    }

    const eidPath = path.join(PLAN_C_TMP_DIR, `${chatId}_${userId}_eid_${Date.now()}.jpg`);
    await downloadTelegramFile(fileId, eidPath);
    session.state = 'OCR_PROCESSING';
    session.eidReceived = true;
    session.eidPath = eidPath;
    setSession(state, chatId, userId, session);
    saveState(state);
    await sendMessage(chatId, 'Đã nhận ảnh CCCD điện tử. Mình đang đọc thông tin...', messageId);

    try {
      const { parsed, rawOcr } = await runEidOcr(session.eidPath);
      session.state = 'WAITING_USER_CONFIRMATION';
      session.ocrResult = parsed.fields;
      setSession(state, chatId, userId, session);
      saveState(state);
      const block = parsed.block || buildCccdDraftBlock();
      await sendMessage(chatId,
`Mình đã bóc được thông tin từ ảnh CCCD điện tử. Hãy kiểm tra, bổ sung các trường còn thiếu, rồi gửi lại nguyên block này để mình tạo hợp đồng:\n\n${block}`,
      messageId);
      if (session.debugOcr) {
        await sendMessage(chatId, `OCR RAW DEBUG:\n${buildOcrDebugText(rawOcr)}`, messageId);
      }
    } catch (err) {
      session.state = 'ERROR';
      session.lastError = String(err.message || err);
      setSession(state, chatId, userId, session);
      saveState(state);
      await sendMessage(chatId, 'Mình chưa OCR được rõ từ ảnh CCCD điện tử. Hãy thử gửi lại ảnh rõ hơn.', messageId);
    }
    return;
  }

  if (session && session.state === 'WAITING_USER_CONFIRMATION') {
    if (!looksLikeContractInput(text)) {
      await sendMessage(chatId, 'Mình đang chờ block xác nhận thông tin hợp đồng theo mẫu. Nếu muốn hủy, gửi /cancel.', messageId);
      return;
    }

    const tempInput = path.join(OUTPUT_DIR, `.telegram-input-${Date.now()}.txt`);
    fs.writeFileSync(tempInput, text, 'utf8');
    try {
      const raw = runCommand('node', ['plan-b-telegram-to-docx.js', tempInput, OUTPUT_DIR, TEMPLATE_DOCX]);
      const result = JSON.parse(raw);
      clearSession(state, chatId, userId);
      saveState(state);
      await sendDocument(chatId, path.resolve(WORKSPACE, result.generated), 'Đã tạo hợp đồng .docx xong.', messageId);
    } catch (err) {
      const output = String(err.stdout || '') || String(err.message || err);
      await sendMessage(chatId, `Không tạo được hợp đồng. Chi tiết:\n${output.slice(0, 3000)}`, messageId);
    } finally {
      try { fs.unlinkSync(tempInput); } catch {}
    }
    return;
  }

  if (!looksLikeContractInput(text)) {
    await sendMessage(chatId, 'Mình chưa nhận ra đây là block input hợp đồng. Gửi /mauhopdong để lấy mẫu chuẩn hoặc /cccd để bắt đầu luồng CCCD.', messageId);
    return;
  }

  const tempInput = path.join(OUTPUT_DIR, `.telegram-input-${Date.now()}.txt`);
  fs.writeFileSync(tempInput, text, 'utf8');
  try {
    const raw = runCommand('node', ['plan-b-telegram-to-docx.js', tempInput, OUTPUT_DIR, TEMPLATE_DOCX]);
    const result = JSON.parse(raw);
    await sendDocument(chatId, path.resolve(WORKSPACE, result.generated), 'Đã tạo hợp đồng .docx xong.', messageId);
  } catch (err) {
    const output = String(err.stdout || '') || String(err.message || err);
    await sendMessage(chatId, `Không tạo được hợp đồng. Chi tiết:\n${output.slice(0, 3000)}`, messageId);
  } finally {
    try { fs.unlinkSync(tempInput); } catch {}
  }
}

async function handleMessage(msg, state) {
  const chatId = msg.chat.id;
  const chatIdStr = String(chatId);
  const text = (msg.text || '').trim();
  const messageId = msg.message_id;
  const chatTitle = msg.chat.title || msg.chat.username || msg.chat.first_name || 'unknown';
  const normalizedCommand = text.split(/\s+/)[0].split('@')[0];
  const isManagement = MANAGEMENT_CHAT_ID && chatIdStr === MANAGEMENT_CHAT_ID;
  const isContract = CONTRACT_CHAT_ID && chatIdStr === CONTRACT_CHAT_ID;

  if (normalizedCommand === '/start') {
    let intro = 'FlexClawBot đã sẵn sàng.';
    if (isManagement) intro += '\n\nGroup này được map cho Plan A (management/employee reminders).';
    else if (isContract) intro += '\n\nGroup này được map cho Plan B/Plan C (hợp đồng + CCCD).';
    intro += '\n\nLệnh hỗ trợ:\n/id - xem chat_id hiện tại';
    if (isContract) intro += '\n/mauhopdong - lấy mẫu input hợp đồng\n/cccd - bắt đầu luồng CCCD\n/cccd_debug - OCR + trả raw debug text\n/status - xem trạng thái hiện tại\n/cancel - hủy phiên';
    await sendMessage(chatId, intro, messageId);
    return;
  }

  if (normalizedCommand === '/id') {
    await sendMessage(chatId, `chat_id: ${chatId}\ntitle: ${chatTitle}\ntype: ${msg.chat.type}`, messageId);
    return;
  }

  if (isManagement) {
    if (normalizedCommand === '/mauhopdong' || normalizedCommand === '/cccd') {
      await sendMessage(chatId, 'Group này dành cho Plan A (management). Chức năng hợp đồng/CCCD chỉ dùng ở group automatic contract.', messageId);
      return;
    }
    await sendMessage(chatId, 'Group này đã được map cho Plan A. Hiện group này dùng để nhận báo cáo management/employee.', messageId);
    return;
  }

  if (isContract) {
    await handleContractFlow(msg, state);
    return;
  }

  await sendMessage(chatId, 'Chat này chưa được map chức năng. Hiện bot chỉ hỗ trợ 2 group nội bộ đã cấu hình.', messageId);
}

async function main() {
  if (!TOKEN) throw new Error('Thiếu TELEGRAM_BOT_TOKEN');
  ensureDir(OUTPUT_DIR);
  const state = loadState();
  while (true) {
    try {
      const updates = await tg('getUpdates', { offset: state.offset + 1, timeout: 20 });
      for (const u of updates) {
        state.offset = u.update_id;
        if (u.message && (u.message.text || isPhotoMessage(u.message))) {
          await handleMessage(u.message, state);
        }
      }
      saveState(state);
    } catch (err) {
      console.error(err.message || err);
      await sleep(POLL_MS);
    }
  }
}

main().catch(err => {
  console.error(err.message || err);
  process.exit(1);
});


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)