auto backup 2026-07-02 11:00

This commit is contained in:
2026-07-02 11:00:05 +08:00
parent 022b6edbe4
commit c7ff34d012
199 changed files with 43050 additions and 0 deletions
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
查看我今天/本周/指定日期的考勤记录(自动获取 userId)
用法:
python attendance_my_record.py # 今天
python attendance_my_record.py today # 今天
python attendance_my_record.py 2026-03-10 # 指定日期
python attendance_my_record.py --dry-run # 仅显示命令
"""
import sys
import json
import subprocess
import re
from datetime import datetime
from typing import List, Any, Optional
DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$')
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def get_my_user_id(dry_run: bool = False) -> Optional[str]:
data = run_dws([
'contact', 'user', 'get-self', '--format', 'json',
], dry_run=dry_run)
if dry_run:
return '<MY_USER_ID>'
if not data or not isinstance(data, dict):
return None
return data.get('userId') or data.get('userid')
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
date_str = args[0] if args else 'today'
if date_str == 'today':
date_str = datetime.now().strftime('%Y-%m-%d')
elif not DATE_PATTERN.match(date_str):
print(__doc__)
sys.exit(1)
print('🔍 获取当前用户信息...')
user_id = get_my_user_id(dry_run=dry_run)
if not user_id and not dry_run:
print('错误:无法获取当前用户 ID')
sys.exit(1)
print(f'📊 查询 {date_str} 考勤记录...\n')
data = run_dws([
'attendance', 'record', 'get',
'--user', user_id or '<MY_USER_ID>',
'--date', date_str,
'--format', 'json',
], dry_run=dry_run)
if dry_run:
return
if not data:
print('未查到考勤记录')
return
print(f"📋 考勤记录 ({date_str})")
print('=' * 40)
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
查询团队成员本周排班和出勤统计
用法:
python attendance_team_shift.py --users userId1,userId2,userId3
python attendance_team_shift.py --users userId1,userId2 \
--from 2026-03-10 --to 2026-03-14
python attendance_team_shift.py --users userId1 --dry-run
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def get_week_range():
today = datetime.now()
monday = today - timedelta(days=today.weekday())
friday = monday + timedelta(days=4)
return monday.strftime('%Y-%m-%d'), friday.strftime('%Y-%m-%d')
def main():
parser = argparse.ArgumentParser(
description='查询团队成员排班和出勤统计'
)
parser.add_argument(
'--users', required=True, help='用户 ID 列表,逗号分隔'
)
mon, fri = get_week_range()
parser.add_argument('--from', dest='from_date', default=mon)
parser.add_argument('--to', dest='to_date', default=fri)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
user_count = len(args.users.split(','))
if user_count > 50:
print('错误:最多查询 50 人')
sys.exit(1)
print(f"📊 团队排班查询 ({args.from_date} ~ {args.to_date})")
print(f" 人数: {user_count}")
print('=' * 50)
print('\n🔍 查询排班信息...')
data = run_dws([
'attendance', 'shift', 'list',
'--users', args.users,
'--start', args.from_date,
'--end', args.to_date,
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
return
if not data:
print('未查到排班信息')
return
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""
批量添加字段到钉钉 AI 表格数据表(新版 schema)
用法:
python bulk_add_fields.py <baseId> <tableId> fields.json
fields.json 格式:
[
{"fieldName": "字段 1", "type": "text"},
{"fieldName": "字段 2", "type": "number", "config": {"formatter": "INT"}},
{"fieldName": "字段 3", "type": "singleSelect", "config": {"options": [{"name": ""}]}}
]
兼容写法:
- name 会自动映射为 fieldName
- phone 会自动映射为 telephone
"""
import sys
import json
import subprocess
import os
import re
from pathlib import Path
from typing import Union, List, Dict, Any, Optional, Tuple
JsonData = Union[List[Any], Dict[str, Any]]
MAX_FILE_SIZE = 10 * 1024 * 1024
ALLOWED_FILE_EXTENSIONS = ['.json']
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
ALLOWED_FIELD_TYPES = {
'text', 'number', 'singleSelect', 'multipleSelect', 'date', 'currency',
'user', 'department', 'group', 'progress', 'rating', 'checkbox',
'attachment', 'url', 'richText', 'telephone', 'email', 'idCard',
'barcode', 'geolocation', 'primaryDoc', 'formula', 'unidirectionalLink',
'bidirectionalLink', 'creator', 'lastModifier', 'createdTime',
'lastModifiedTime',
}
FIELD_TYPE_ALIASES = {
'phone': 'telephone',
}
def resolve_safe_path(path: str, allowed_root: Optional[str] = None) -> Path:
if allowed_root is None:
allowed_root = os.environ.get('OPENCLAW_WORKSPACE', os.getcwd())
allowed_root = Path(allowed_root).resolve()
target_path = (
Path(path).resolve()
if Path(path).is_absolute()
else (Path.cwd() / path).resolve()
)
try:
target_path.relative_to(allowed_root)
return target_path
except ValueError:
raise ValueError(
f"路径超出允许范围:{path}\n"
f"目标路径:{target_path}\n"
f"允许根目录:{allowed_root}\n"
f"提示:设置 OPENCLAW_WORKSPACE 环境变量或确保文件在工作目录内"
)
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def validate_file_extension(filename: str, allowed_extensions: list) -> bool:
return any(filename.lower().endswith(ext) for ext in allowed_extensions)
def safe_json_load(file_path: Path, max_size: int = MAX_FILE_SIZE) -> JsonData:
file_size = file_path.stat().st_size
if file_size > max_size:
raise ValueError(
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
)
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def normalize_field_config(field: Dict[str, Any]) -> Dict[str, Any]:
normalized = dict(field)
if 'fieldName' not in normalized and 'name' in normalized:
normalized['fieldName'] = normalized.pop('name')
normalized['type'] = FIELD_TYPE_ALIASES.get(
normalized.get('type', 'text'), normalized.get('type', 'text')
)
return normalized
def validate_field_config(field: Dict[str, Any]) -> Tuple[bool, str]:
if not isinstance(field, dict):
return False, '字段配置必须是对象'
field = normalize_field_config(field)
if 'fieldName' not in field:
return False, '缺少必需字段:fieldName'
if not isinstance(field['fieldName'], str) or not field['fieldName'].strip():
return False, 'fieldName 必须是非空字符串'
field_type = field.get('type', 'text')
if field_type not in ALLOWED_FIELD_TYPES:
return False, f"不支持的字段类型:{field_type}"
config = field.get('config')
if config is not None and not isinstance(config, dict):
return False, 'config 必须是对象'
if field_type in {'singleSelect', 'multipleSelect'}:
options = (config or {}).get('options')
if not options or not isinstance(options, list):
return False, (
'singleSelect / multipleSelect 必须提供 config.options 数组'
)
if field_type in {'unidirectionalLink', 'bidirectionalLink'}:
linked_sheet_id = (config or {}).get('linkedSheetId')
if not linked_sheet_id or not validate_resource_id(linked_sheet_id):
return False, '关联字段必须提供合法的 config.linkedSheetId'
return True, ''
def build_fields_json(fields: List[Dict[str, Any]]) -> str:
"""构建 --fields 参数的 JSON 字符串。"""
payload_fields = []
for field in fields:
normalized = normalize_field_config(field)
item: Dict[str, Any] = {
'fieldName': normalized['fieldName'].strip(),
'type': normalized.get('type', 'text'),
}
if 'config' in normalized and normalized['config'] is not None:
item['config'] = normalized['config']
payload_fields.append(item)
return json.dumps(payload_fields, ensure_ascii=False)
def run_dws(args: List[str]) -> Optional[Dict[str, Any]]:
if not args:
print('错误:空命令')
return None
cmd = ['dws'] + args
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}")
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
print(f"无法解析响应:{result.stdout[:200]}...")
print(f"JSON 解析错误:{e}")
return None
except subprocess.TimeoutExpired:
print('错误:命令执行超时(60 秒)')
return None
except FileNotFoundError:
print('错误:未找到 dws 命令,请确认已安装')
return None
def bulk_add_fields(
base_id: str, table_id: str, fields_file: str
) -> bool:
try:
safe_path = resolve_safe_path(fields_file)
except ValueError as e:
print(f"路径验证失败:{e}")
return False
if not validate_file_extension(fields_file, ALLOWED_FILE_EXTENSIONS):
print(f"错误:只允许 {', '.join(ALLOWED_FILE_EXTENSIONS)} 文件")
return False
if not safe_path.exists():
print(f"错误:文件不存在:{safe_path}")
return False
try:
fields = safe_json_load(safe_path)
except ValueError as e:
print(f"错误:{e}")
return False
except json.JSONDecodeError as e:
print(f"错误:JSON 格式无效:{e}")
return False
if not isinstance(fields, list) or not fields:
print('错误:fields.json 必须是非空 JSON 数组')
return False
if len(fields) > 15:
print('错误:单次最多创建 15 个字段,请拆分后重试')
return False
for i, field in enumerate(fields):
valid, error = validate_field_config(field)
if not valid:
print(f"错误:字段 #{i+1} 配置无效:{error}")
return False
fields_json = build_fields_json(fields)
result = run_dws([
'aitable', 'field', 'create',
'--base-id', base_id,
'--table-id', table_id,
'--fields', fields_json,
'--format', 'json',
])
if not result:
return False
print(json.dumps(result, ensure_ascii=False, indent=2))
return True
def main():
if len(sys.argv) != 4:
print(__doc__)
print('用法示例:')
print(' python bulk_add_fields.py basexxx tablexxx fields.json')
sys.exit(1)
base_id = sys.argv[1]
table_id = sys.argv[2]
fields_file = sys.argv[3]
if not validate_resource_id(base_id):
print('错误:无效的 baseId 格式')
sys.exit(1)
if not validate_resource_id(table_id):
print('错误:无效的 tableId 格式')
sys.exit(1)
success = bulk_add_fields(base_id, table_id, fields_file)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""
查询多人共同空闲时段,推荐最佳会议时间
用法:
python calendar_free_slot_finder.py \
--users userId1,userId2,userId3 \
--date 2026-03-15 \
--duration 60
python calendar_free_slot_finder.py \
--users userId1,userId2 \
--date 2026-03-15 \
--start-hour 9 --end-hour 18 \
--duration 30 --dry-run
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any, Optional, Tuple
TZ = timezone(timedelta(hours=8))
SLOT_STEP_MIN = 30
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def fmt_iso(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
def parse_busy_intervals(
data: Any,
) -> List[Tuple[datetime, datetime]]:
intervals = []
if not data:
return intervals
items = []
if isinstance(data, list):
items = data
elif isinstance(data, dict):
for user_data in data.values():
if isinstance(user_data, list):
items.extend(user_data)
elif isinstance(user_data, dict):
items.extend(
user_data.get('busyTimes', [])
)
for item in items:
start_str = item.get('startTime') or item.get('start', '')
end_str = item.get('endTime') or item.get('end', '')
if not start_str or not end_str:
continue
for fmt in (
'%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S',
'%Y-%m-%dT%H:%M%z',
):
try:
s = datetime.strptime(start_str, fmt)
e = datetime.strptime(end_str, fmt)
if s.tzinfo is None:
s = s.replace(tzinfo=TZ)
if e.tzinfo is None:
e = e.replace(tzinfo=TZ)
intervals.append((s, e))
break
except ValueError:
continue
return intervals
def find_free_slots(
day_start: datetime, day_end: datetime,
busy: List[Tuple[datetime, datetime]],
duration_min: int,
) -> List[Tuple[datetime, datetime]]:
busy_sorted = sorted(busy, key=lambda x: x[0])
merged: List[Tuple[datetime, datetime]] = []
for s, e in busy_sorted:
if merged and s <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
else:
merged.append((s, e))
free: List[Tuple[datetime, datetime]] = []
cursor = day_start
for bs, be in merged:
if cursor < bs:
gap = (bs - cursor).total_seconds() / 60
if gap >= duration_min:
free.append((cursor, bs))
cursor = max(cursor, be)
if cursor < day_end:
gap = (day_end - cursor).total_seconds() / 60
if gap >= duration_min:
free.append((cursor, day_end))
return free
def main():
parser = argparse.ArgumentParser(
description='查询多人共同空闲时段'
)
parser.add_argument(
'--users', required=True, help='用户 ID 列表,逗号分隔'
)
parser.add_argument(
'--date', required=True, help='查询日期 YYYY-MM-DD'
)
parser.add_argument(
'--duration', type=int, default=60,
help='会议时长(分钟),默认 60',
)
parser.add_argument(
'--start-hour', type=int, default=9,
help='工作日开始小时,默认 9',
)
parser.add_argument(
'--end-hour', type=int, default=18,
help='工作日结束小时,默认 18',
)
parser.add_argument(
'--dry-run', action='store_true', help='仅显示命令'
)
args = parser.parse_args()
try:
date = datetime.strptime(args.date, '%Y-%m-%d')
except ValueError:
print('错误:日期格式应为 YYYY-MM-DD')
sys.exit(1)
day_start = date.replace(
hour=args.start_hour, tzinfo=TZ
)
day_end = date.replace(hour=args.end_hour, tzinfo=TZ)
data = run_dws([
'calendar', 'busy', 'search',
'--users', args.users,
'--start', fmt_iso(day_start),
'--end', fmt_iso(day_end),
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
return
busy = parse_busy_intervals(data)
free = find_free_slots(day_start, day_end, busy, args.duration)
users_list = args.users.split(',')
print(f"\n🕐 空闲时段查询 ({args.date})")
print(f" 参与人: {len(users_list)}")
print(f" 会议时长: {args.duration} 分钟")
print(f" 工作时间: {args.start_hour}:00 ~ "
f"{args.end_hour}:00")
print('=' * 50)
if not free:
print(' ❌ 该日无共同空闲时段')
return
print(f"\n✅ 找到 {len(free)} 个可用时段:\n")
for i, (s, e) in enumerate(free, 1):
gap_min = int((e - s).total_seconds() / 60)
label = '⭐ 推荐' if i == 1 else f' 备选{i-1}'
print(f" {label} {s.strftime('%H:%M')} ~ "
f"{e.strftime('%H:%M')} ({gap_min}分钟)")
if __name__ == '__main__':
main()
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
一键创建日程 + 添加参与者 + 搜索并预定空闲会议室
用法:
python calendar_schedule_meeting.py \
--title "Q1 复盘会" \
--start "2026-03-15T14:00" \
--end "2026-03-15T15:00" \
--users userId1,userId2 \
--book-room
python calendar_schedule_meeting.py --dry-run \
--title "测试" --start "2026-03-15T14:00" --end "2026-03-15T15:00"
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any, Optional
TZ = timezone(timedelta(hours=8))
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def normalize_time(time_str: str) -> str:
for fmt in ('%Y-%m-%dT%H:%M', '%Y-%m-%d %H:%M',
'%Y-%m-%dT%H:%M:%S'):
try:
dt = datetime.strptime(time_str, fmt)
dt = dt.replace(tzinfo=TZ)
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
except ValueError:
continue
if '+' in time_str or time_str.endswith('Z'):
return time_str
raise ValueError(f"无法解析时间:{time_str}")
def main():
parser = argparse.ArgumentParser(
description='一键创建日程 + 添加参与者 + 预定会议室'
)
parser.add_argument('--title', required=True, help='日程标题')
parser.add_argument('--start', required=True, help='开始时间')
parser.add_argument('--end', required=True, help='结束时间')
parser.add_argument('--desc', default='', help='日程描述')
parser.add_argument('--users', default='', help='参与者 userId')
parser.add_argument(
'--book-room', action='store_true', help='自动预定会议室'
)
parser.add_argument(
'--dry-run', action='store_true', help='仅显示命令'
)
args = parser.parse_args()
try:
start_iso = normalize_time(args.start)
end_iso = normalize_time(args.end)
except ValueError as e:
print(f"错误:{e}")
sys.exit(1)
print('📅 创建日程...')
create_args = [
'calendar', 'event', 'create',
'--title', args.title,
'--start', start_iso,
'--end', end_iso,
'--format', 'json',
]
if args.desc:
create_args.extend(['--desc', args.desc])
result = run_dws(create_args, dry_run=args.dry_run)
if not result:
sys.exit(1)
event_id = None
if not args.dry_run and isinstance(result, dict):
event_id = result.get('eventId') or result.get('id')
print(f" ✓ 日程已创建" +
(f" (eventId: {event_id})" if event_id else ""))
if args.users and event_id:
print('\n👥 添加参与者...')
r = run_dws([
'calendar', 'participant', 'add',
'--event', event_id,
'--users', args.users,
'--format', 'json',
], dry_run=args.dry_run)
if r:
print(f" ✓ 已添加参与者: {args.users}")
elif args.users and args.dry_run:
run_dws([
'calendar', 'participant', 'add',
'--event', '<EVENT_ID>',
'--users', args.users,
'--format', 'json',
], dry_run=True)
if args.book_room:
print('\n🏢 搜索空闲会议室...')
rooms_data = run_dws([
'calendar', 'room', 'search',
'--start', start_iso,
'--end', end_iso,
'--available',
'--format', 'json',
], dry_run=args.dry_run)
if not args.dry_run and rooms_data:
rooms = (rooms_data if isinstance(rooms_data, list)
else rooms_data.get('rooms', []))
if rooms:
room = rooms[0]
room_id = room.get('roomId') or room.get('id')
room_name = room.get('roomName') or room.get('name')
print(f" 找到空闲会议室: {room_name}")
if event_id and room_id:
r = run_dws([
'calendar', 'room', 'add',
'--event', event_id,
'--rooms', str(room_id),
'--format', 'json',
], dry_run=args.dry_run)
if r:
print(f" ✓ 已预定: {room_name}")
else:
print(' ⚠ 该时段无空闲会议室')
print('\n✅ 完成!')
if __name__ == '__main__':
main()
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
查看今天/明天/本周的日程安排
用法:
python calendar_today_agenda.py # 今天
python calendar_today_agenda.py today # 今天
python calendar_today_agenda.py tomorrow # 明天
python calendar_today_agenda.py week # 本周
python calendar_today_agenda.py --dry-run # 仅显示命令
"""
import sys
import json
import subprocess
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any, Optional
TZ = timezone(timedelta(hours=8))
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def get_range(scope: str):
now = datetime.now(TZ)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
if scope == 'today':
return today, today + timedelta(days=1)
elif scope == 'tomorrow':
t = today + timedelta(days=1)
return t, t + timedelta(days=1)
elif scope == 'week':
ws = today - timedelta(days=today.weekday())
return ws, ws + timedelta(days=7)
return today, today + timedelta(days=1)
def fmt_iso(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
def fmt_time(iso_str: str) -> str:
if not iso_str:
return '??:??'
try:
for fmt in ('%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S'):
try:
dt = datetime.strptime(iso_str, fmt)
return dt.strftime('%H:%M')
except ValueError:
continue
return iso_str[:16]
except Exception:
return iso_str[:16]
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
scope = args[0] if args else 'today'
if scope not in ('today', 'tomorrow', 'week'):
print(__doc__)
sys.exit(1)
start, end = get_range(scope)
data = run_dws([
'calendar', 'event', 'list',
'--start', fmt_iso(start),
'--end', fmt_iso(end),
'--format', 'json',
], dry_run=dry_run)
if dry_run:
return
events = []
if isinstance(data, list):
events = data
elif isinstance(data, dict):
events = data.get('events', data.get('result', []))
label = {'today': '今天', 'tomorrow': '明天', 'week': '本周'
}.get(scope, scope)
print(f"\n📅 {label}日程 ({start.strftime('%m-%d')} ~ "
f"{end.strftime('%m-%d')})")
print('=' * 50)
if not events:
print(' ✅ 暂无日程,自由安排!')
return
for e in events:
title = e.get('summary') or e.get('title', '无标题')
s = e.get('start', {})
ed = e.get('end', {})
start_t = fmt_time(
s.get('dateTime', '') if isinstance(s, dict) else str(s)
)
end_t = fmt_time(
ed.get('dateTime', '') if isinstance(ed, dict) else str(ed)
)
loc = e.get('location', {})
loc_str = (loc.get('displayName', '')
if isinstance(loc, dict) else str(loc or ''))
line = f" 🕐 {start_t}-{end_t} {title}"
if loc_str:
line += f" 📍{loc_str}"
print(line)
print(f"\n合计: {len(events)} 场日程")
if __name__ == '__main__':
main()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
按部门名称搜索并列出所有成员(自动 deptId 解析)
用法:
python contact_dept_members.py --query "技术部"
python contact_dept_members.py --query "产品" --dry-run
"""
import sys
import json
import subprocess
import argparse
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def main():
parser = argparse.ArgumentParser(
description='按部门名称搜索并列出所有成员'
)
parser.add_argument(
'--query', required=True, help='部门名称关键词'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
print(f'🔍 搜索部门: {args.query}')
dept_data = run_dws([
'contact', 'dept', 'search',
'--query', args.query, '--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
run_dws([
'contact', 'dept', 'list-members',
'--ids', '<DEPT_ID>', '--format', 'json',
], dry_run=True)
return
if not dept_data:
print('未找到匹配部门')
sys.exit(1)
depts = (dept_data if isinstance(dept_data, list)
else dept_data.get('result', dept_data.get('items', [])))
if not depts:
print('未找到匹配部门')
sys.exit(1)
for dept in depts:
dept_id = dept.get('id') or dept.get('deptId')
dept_name = dept.get('name') or dept.get('deptName', '未知')
if not dept_id:
continue
print(f"\n📂 {dept_name} (ID: {dept_id})")
print('-' * 40)
members_data = run_dws([
'contact', 'dept', 'list-members',
'--ids', str(dept_id), '--format', 'json',
])
if not members_data:
print(' 无法获取成员列表')
continue
members = (members_data if isinstance(members_data, list)
else members_data.get('result',
members_data.get('userlist', [])))
if not members:
print(' (暂无成员)')
continue
for m in members:
name = m.get('name') or m.get('userName', '未知')
title = m.get('title') or m.get('position', '')
uid = m.get('userId') or m.get('userid', '')
line = f" 👤 {name}"
if title:
line += f" ({title})"
if uid:
line += f" [ID: {uid}]"
print(line)
print(f"{len(members)}")
if __name__ == '__main__':
main()
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env python3
"""
从 CSV / JSON 批量导入记录到钉钉 AI 表格(新版 schema)
用法:
python import_records.py <baseId> <tableId> data.csv [batch_size]
python import_records.py <baseId> <tableId> data.json [batch_size]
说明:
- CSV 表头默认视为 fieldId
- JSON 支持两种格式:
1. [{"cells": {"fldxxx": "value"}}, ...]
2. [{"fldxxx": "value"}, ...] # 会自动包装成 cells
"""
import sys
import csv
import json
import subprocess
import os
import re
from pathlib import Path
from typing import Union, List, Dict, Any, Optional, Tuple
JsonData = Union[List[Any], Dict[str, Any]]
RecordDict = Dict[str, str]
MAX_FILE_SIZE = 50 * 1024 * 1024
ALLOWED_CSV_EXTENSIONS = ['.csv']
ALLOWED_JSON_EXTENSIONS = ['.json']
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
MAX_RECORDS_PER_BATCH = 100
DEFAULT_BATCH_SIZE = 50
def resolve_safe_path(
path: str, allowed_root: Optional[str] = None
) -> Path:
if allowed_root is None:
allowed_root = os.environ.get('OPENCLAW_WORKSPACE', os.getcwd())
allowed_root = Path(allowed_root).resolve()
target_path = (
Path(path).resolve()
if Path(path).is_absolute()
else (Path.cwd() / path).resolve()
)
try:
target_path.relative_to(allowed_root)
return target_path
except ValueError:
raise ValueError(
f"路径超出允许范围:{path}\n"
f"目标路径:{target_path}\n"
f"允许根目录:{allowed_root}\n"
f"提示:设置 OPENCLAW_WORKSPACE 环境变量或确保文件在工作目录内"
)
def validate_resource_id(resource_id: str) -> bool:
return bool(
resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip())
)
def validate_file_extension(
filename: str, allowed_extensions: list
) -> bool:
return any(filename.lower().endswith(ext) for ext in allowed_extensions)
def safe_csv_load(
file_path: Path, max_size: int = MAX_FILE_SIZE
) -> List[RecordDict]:
file_size = file_path.stat().st_size
if file_size > max_size:
raise ValueError(
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
)
with open(file_path, 'r', encoding='utf-8', newline='') as f:
return list(csv.DictReader(f))
def safe_json_load(
file_path: Path, max_size: int = MAX_FILE_SIZE
) -> JsonData:
file_size = file_path.stat().st_size
if file_size > max_size:
raise ValueError(
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
)
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def sanitize_record_value(
value: Any,
) -> Optional[Union[str, int, float, bool, list, dict]]:
if value is None:
return None
if isinstance(value, (bool, int, float, list, dict)):
return value
if not isinstance(value, str):
return value
if not value.strip():
return None
value = value.strip()
if value.lower() == 'true':
return True
if value.lower() == 'false':
return False
try:
if '.' in value:
return float(value)
return int(value)
except ValueError:
return value
def normalize_record(record: Dict[str, Any]) -> Dict[str, Any]:
if 'cells' in record and isinstance(record['cells'], dict):
cells = record['cells']
else:
cells = record
normalized = {}
for key, value in cells.items():
sanitized = sanitize_record_value(value)
if sanitized is not None:
normalized[key] = sanitized
return {'cells': normalized}
def validate_record(
record: Dict[str, Any], headers: List[str]
) -> Tuple[bool, str]:
if not isinstance(record, dict):
return False, '记录必须是对象'
normalized = normalize_record(record)
cells = normalized.get('cells', {})
if not cells or not isinstance(cells, dict):
return False, '记录必须包含非空 cells 对象'
return True, ''
def run_dws(args: List[str]) -> Optional[Dict[str, Any]]:
if not args:
print('错误:空命令')
return None
cmd = ['dws'] + args
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}")
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
print(f"无法解析响应:{result.stdout[:200]}...")
print(f"JSON 解析错误:{e}")
return None
except subprocess.TimeoutExpired:
print('错误:命令执行超时(120 秒)')
return None
except FileNotFoundError:
print('错误:未找到 dws 命令,请确认已安装')
return None
def import_from_csv(
base_id: str, table_id: str, csv_file: str,
batch_size: int = DEFAULT_BATCH_SIZE,
) -> bool:
try:
safe_path = resolve_safe_path(csv_file)
except ValueError as e:
print(f"路径验证失败:{e}")
return False
if not validate_file_extension(csv_file, ALLOWED_CSV_EXTENSIONS):
print(f"错误:只允许 {', '.join(ALLOWED_CSV_EXTENSIONS)} 文件")
return False
if not safe_path.exists():
print(f"错误:文件不存在:{safe_path}")
return False
try:
rows = safe_csv_load(safe_path)
except ValueError as e:
print(f"错误:{e}")
return False
except csv.Error as e:
print(f"错误:CSV 格式无效:{e}")
return False
if not rows:
print('错误:CSV 文件为空或没有有效数据行')
return False
records = [
normalize_record(row)
for row in rows
if normalize_record(row)['cells']
]
return import_records(base_id, table_id, records, batch_size)
def import_from_json(
base_id: str, table_id: str, json_file: str,
batch_size: int = DEFAULT_BATCH_SIZE,
) -> bool:
try:
safe_path = resolve_safe_path(json_file)
except ValueError as e:
print(f"路径验证失败:{e}")
return False
if not validate_file_extension(json_file, ALLOWED_JSON_EXTENSIONS):
print(f"错误:只允许 {', '.join(ALLOWED_JSON_EXTENSIONS)} 文件")
return False
if not safe_path.exists():
print(f"错误:文件不存在:{safe_path}")
return False
try:
records = safe_json_load(safe_path)
except ValueError as e:
print(f"错误:{e}")
return False
except json.JSONDecodeError as e:
print(f"错误:JSON 格式无效:{e}")
return False
if not isinstance(records, list) or not records:
print('错误:JSON 文件必须是非空数组')
return False
for i, record in enumerate(records):
valid, error = validate_record(record, [])
if not valid:
print(f"错误:记录 #{i+1} 格式无效:{error}")
return False
return import_records(
base_id, table_id,
[normalize_record(r) for r in records], batch_size,
)
def import_records(
base_id: str, table_id: str,
records: List[Dict[str, Any]], batch_size: int,
) -> bool:
if batch_size <= 0:
print('错误:batch_size 必须大于 0')
return False
if batch_size > MAX_RECORDS_PER_BATCH:
batch_size = MAX_RECORDS_PER_BATCH
total_batches = (len(records) + batch_size - 1) // batch_size
success = True
for i in range(0, len(records), batch_size):
batch = records[i:i + batch_size]
batch_num = (i // batch_size) + 1
records_json = json.dumps(batch, ensure_ascii=False)
result = run_dws([
'aitable', 'record', 'create',
'--base-id', base_id,
'--table-id', table_id,
'--records', records_json,
'--format', 'json',
])
if result:
print(
f"[{batch_num}/{total_batches}] "
f"✓ 已提交 {len(batch)} 条记录"
)
else:
print(f"[{batch_num}/{total_batches}] ✗ 导入失败")
success = False
return success
def main():
if len(sys.argv) < 4 or len(sys.argv) > 5:
print(__doc__)
print('用法示例:')
print(
' python import_records.py basexxx tablexxx data.csv 50'
)
sys.exit(1)
base_id = sys.argv[1]
table_id = sys.argv[2]
input_file = sys.argv[3]
batch_size = (
int(sys.argv[4]) if len(sys.argv) == 5
else DEFAULT_BATCH_SIZE
)
if not validate_resource_id(base_id):
print('错误:无效的 baseId 格式')
sys.exit(1)
if not validate_resource_id(table_id):
print('错误:无效的 tableId 格式')
sys.exit(1)
if input_file.lower().endswith('.csv'):
success = import_from_csv(
base_id, table_id, input_file, batch_size
)
elif input_file.lower().endswith('.json'):
success = import_from_json(
base_id, table_id, input_file, batch_size
)
else:
print('错误:仅支持 .csv 或 .json 文件')
sys.exit(1)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
查看今天收到的日志列表及详情
用法:
python report_inbox_today.py
python report_inbox_today.py --days 3 # 最近 3 天
python report_inbox_today.py --dry-run
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def to_iso(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
def main():
parser = argparse.ArgumentParser(
description='查看收到的日志'
)
parser.add_argument(
'--days', type=int, default=1, help='查询天数 (默认 1)'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
now = datetime.now()
start = now - timedelta(days=args.days)
start = start.replace(hour=0, minute=0, second=0)
label = '今天' if args.days == 1 else f'最近 {args.days}'
print(f'📓 查看{label}收到的日志...\n')
data = run_dws([
'report', 'list',
'--start', to_iso(start),
'--end', to_iso(now),
'--cursor', '0',
'--size', '20',
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
return
if not data:
print('未查到日志')
return
reports = (data if isinstance(data, list)
else data.get('result', data.get('reports', [])))
if not reports:
print(' ✅ 暂无收到的日志')
return
print(f"📓 {label}日志 ({len(reports)} 条)")
print('=' * 50)
for r in reports:
rid = r.get('reportId') or r.get('id', '')
creator = r.get('creatorName') or r.get('creator', '未知')
template = r.get('templateName') or r.get('template', '')
create_time = r.get('createTime', '')
if isinstance(create_time, (int, float)):
create_time = datetime.fromtimestamp(
create_time / 1000
).strftime('%Y-%m-%d %H:%M')
print(f"\n 📝 {template or '日志'} - {creator}")
print(f" 时间: {create_time}")
print(f" ID: {rid}")
if rid:
detail = run_dws([
'report', 'detail',
'--report-id', rid, '--format', 'json',
])
if detail and isinstance(detail, dict):
contents = detail.get('contents', [])
for c in contents[:3]:
key = c.get('key') or c.get('title', '')
val = c.get('value') or c.get('content', '')
if key and val:
print(f" {key}: {str(val)[:60]}")
if __name__ == '__main__':
main()
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
从 JSON 文件批量创建待办(含优先级、截止时间、执行者)
用法:
python todo_batch_create.py todos.json
python todo_batch_create.py todos.json --dry-run
todos.json 格式:
[
{"title": "修复线上Bug", "executors": "userId1,userId2", "priority": 40},
{"title": "写周报", "executors": "userId1", "due": "2026-03-15"},
{"title": "代码评审", "executors": "userId1"},
{"title": "每日站会", "executors": "userId1", "due": "2026-03-20",
"recurrence": "DTSTART:20260320T020000Z\\nRRULE:FREQ=DAILY;INTERVAL=1"}
]
字段说明:
- title: 待办标题 (必填)
- executors: 执行者 userId,多人逗号分隔 (必填)
- priority: 优先级 10=低/20=普通/30=较高/40=紧急 (可选)
- due: 截止日期 YYYY-MM-DD 或毫秒时间戳 (可选)
- recurrence: 循环待办规则 (可选,需同时有 due);字符串内需含换行,与 dws --recurrence 一致
"""
import sys
import json
import subprocess
import re
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any, Optional
ALLOWED_PRIORITIES = {10, 20, 30, 40}
DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$')
MAX_FILE_SIZE = 10 * 1024 * 1024
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Dict[str, Any]]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
print(' ✗ 命令执行超时', file=sys.stderr)
return None
except (json.JSONDecodeError, FileNotFoundError) as e:
print(f" ✗ 错误:{e}", file=sys.stderr)
return None
def parse_due(due_value) -> Optional[str]:
if not due_value:
return None
due_str = str(due_value)
if due_str.isdigit() and len(due_str) >= 10:
return due_str
if DATE_PATTERN.match(due_str):
dt = datetime.strptime(due_str, '%Y-%m-%d')
dt = dt.replace(hour=23, minute=59, second=59)
return str(int(dt.timestamp() * 1000))
print(f" ⚠ 无法解析截止时间:{due_value},跳过")
return None
def validate_todo(item: Dict[str, Any], idx: int) -> bool:
if not isinstance(item, dict):
print(f" ✗ #{idx+1} 不是有效对象")
return False
if not item.get('title', '').strip():
print(f" ✗ #{idx+1} 缺少 title")
return False
if not item.get('executors', '').strip():
print(f" ✗ #{idx+1} 缺少 executors")
return False
priority = item.get('priority')
if priority is not None and int(priority) not in ALLOWED_PRIORITIES:
print(f" ✗ #{idx+1} 无效优先级:{priority}")
return False
recurrence = item.get('recurrence')
if recurrence and not str(recurrence).strip():
print(f" ✗ #{idx+1} recurrence 不能为空字符串")
return False
if recurrence and not item.get('due'):
print(f" ✗ #{idx+1} 设置 recurrence 时必须提供 due")
return False
return True
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
if not args:
print(__doc__)
sys.exit(1)
file_path = Path(args[0])
if not file_path.exists():
print(f"错误:文件不存在:{file_path}")
sys.exit(1)
if file_path.stat().st_size > MAX_FILE_SIZE:
print(f"错误:文件过大 (限制 {MAX_FILE_SIZE // 1024}KB)")
sys.exit(1)
with open(file_path, 'r', encoding='utf-8') as f:
todos = json.load(f)
if not isinstance(todos, list) or not todos:
print('错误:JSON 文件必须是非空数组')
sys.exit(1)
for i, item in enumerate(todos):
if not validate_todo(item, i):
sys.exit(1)
print(f"📋 准备创建 {len(todos)} 条待办\n")
success, fail = 0, 0
for i, item in enumerate(todos):
title = item['title'].strip()
cmd_args = [
'todo', 'task', 'create',
'--title', title,
'--executors', item['executors'].strip(),
'--format', 'json',
]
priority = item.get('priority')
if priority is not None:
cmd_args.extend(['--priority', str(int(priority))])
due = parse_due(item.get('due'))
if due:
cmd_args.extend(['--due', due])
recurrence = item.get('recurrence')
if recurrence:
rr = str(recurrence).replace('\\n', '\n')
cmd_args.extend(['--recurrence', rr])
result = run_dws(cmd_args, dry_run=dry_run)
if result:
print(f" ✓ [{i+1}/{len(todos)}] {title}")
success += 1
else:
print(f" ✗ [{i+1}/{len(todos)}] {title}")
fail += 1
print(f"\n完成: 成功 {success}, 失败 {fail}")
sys.exit(0 if fail == 0 else 1)
if __name__ == '__main__':
main()
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""
查询今天/明天/本周未完成的待办并汇总输出
用法:
python todo_daily_summary.py # 默认查今天
python todo_daily_summary.py today # 今天的待办
python todo_daily_summary.py tomorrow # 明天的待办
python todo_daily_summary.py week # 本周的待办
python todo_daily_summary.py --dry-run # 仅显示将执行的命令
"""
import sys
import json
import subprocess
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
PRIORITY_MAP = {10: '', 20: '普通', 30: '较高', 40: '紧急'}
PAGE_SIZE = 50
MAX_PAGES = 10
def run_dws(args: List[str], dry_run: bool = False) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
print('错误:命令执行超时', file=sys.stderr)
return None
except (json.JSONDecodeError, FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def get_date_range(scope: str):
now = datetime.now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
if scope == 'today':
return today_start, today_start + timedelta(days=1)
elif scope == 'tomorrow':
tmr = today_start + timedelta(days=1)
return tmr, tmr + timedelta(days=1)
elif scope == 'week':
week_start = today_start - timedelta(days=today_start.weekday())
return week_start, week_start + timedelta(days=7)
return today_start, today_start + timedelta(days=1)
def fetch_all_todos(
dry_run: bool = False,
) -> List[Dict[str, Any]]:
all_todos: List[Dict[str, Any]] = []
for page in range(1, MAX_PAGES + 1):
data = run_dws([
'todo', 'task', 'list',
'--page', str(page),
'--size', str(PAGE_SIZE),
'--status', 'false',
'--format', 'json',
], dry_run=dry_run)
if dry_run:
return []
if not data:
break
items = data if isinstance(data, list) else data.get(
'result', data.get('todoCards', [])
)
if not items or not isinstance(items, list):
break
all_todos.extend(items)
if len(items) < PAGE_SIZE:
break
return all_todos
def format_priority(p) -> str:
try:
return PRIORITY_MAP.get(int(p), str(p))
except (ValueError, TypeError):
return '普通'
def format_due(due_ms) -> str:
if not due_ms:
return '无截止时间'
try:
dt = datetime.fromtimestamp(int(due_ms) / 1000)
return dt.strftime('%Y-%m-%d %H:%M')
except (ValueError, TypeError, OSError):
return str(due_ms)
def filter_by_due(
todos: List[Dict[str, Any]], start: datetime, end: datetime,
) -> List[Dict[str, Any]]:
start_ms = int(start.timestamp() * 1000)
end_ms = int(end.timestamp() * 1000)
result = []
for t in todos:
due = t.get('dueTime') or t.get('due')
if not due:
result.append(t)
continue
try:
due_val = int(due)
if start_ms <= due_val < end_ms:
result.append(t)
except (ValueError, TypeError):
result.append(t)
return result
def print_summary(
todos: List[Dict[str, Any]], scope: str,
start: datetime, end: datetime,
):
scope_label = {
'today': '今天', 'tomorrow': '明天', 'week': '本周',
}.get(scope, scope)
print(f"\n📋 {scope_label}未完成待办 "
f"({start.strftime('%m-%d')} ~ {end.strftime('%m-%d')})")
print('=' * 50)
if not todos:
print(' ✅ 暂无待办,轻松一下!')
return
urgent = [t for t in todos if format_priority(
t.get('priority')) == '紧急']
if urgent:
print(f"\n🔴 紧急 ({len(urgent)} 条)")
for t in urgent:
title = t.get('subject') or t.get('title', '无标题')
print(f"{title}{format_due(t.get('dueTime'))}")
normal = [t for t in todos if t not in urgent]
if normal:
print(f"\n📌 其他 ({len(normal)} 条)")
for t in normal:
title = t.get('subject') or t.get('title', '无标题')
pri = format_priority(t.get('priority'))
print(f" • [{pri}] {title}{format_due(t.get('dueTime'))}")
print(f"\n合计: {len(todos)} 条待办")
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
scope = args[0] if args else 'today'
if scope not in ('today', 'tomorrow', 'week'):
print(__doc__)
sys.exit(1)
start, end = get_date_range(scope)
todos = fetch_all_todos(dry_run=dry_run)
if dry_run:
return
filtered = filter_by_due(todos, start, end)
print_summary(filtered, scope, start, end)
if __name__ == '__main__':
main()
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
扫描已过截止时间但未完成的待办,输出逾期清单
用法:
python todo_overdue_check.py
python todo_overdue_check.py --dry-run
"""
import sys
import json
import subprocess
from datetime import datetime
from typing import List, Dict, Any, Optional
PAGE_SIZE = 50
MAX_PAGES = 10
PRIORITY_MAP = {10: '', 20: '普通', 30: '较高', 40: '紧急'}
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def fetch_all_undone(dry_run: bool = False) -> List[Dict[str, Any]]:
all_todos: List[Dict[str, Any]] = []
for page in range(1, MAX_PAGES + 1):
data = run_dws([
'todo', 'task', 'list',
'--page', str(page), '--size', str(PAGE_SIZE),
'--status', 'false', '--format', 'json',
], dry_run=dry_run)
if dry_run or not data:
break
items = (data if isinstance(data, list)
else data.get('result', data.get('todoCards', [])))
if not items or not isinstance(items, list):
break
all_todos.extend(items)
if len(items) < PAGE_SIZE:
break
return all_todos
def find_overdue(todos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
now_ms = int(datetime.now().timestamp() * 1000)
overdue = []
for t in todos:
due = t.get('dueTime') or t.get('due')
if not due:
continue
try:
if int(due) < now_ms:
overdue.append(t)
except (ValueError, TypeError):
continue
return overdue
def days_overdue(due_ms) -> int:
now = datetime.now()
try:
due_dt = datetime.fromtimestamp(int(due_ms) / 1000)
return max(0, (now - due_dt).days)
except (ValueError, TypeError, OSError):
return 0
def main():
dry_run = '--dry-run' in sys.argv
todos = fetch_all_undone(dry_run=dry_run)
if dry_run:
return
overdue = find_overdue(todos)
overdue.sort(
key=lambda t: int(t.get('dueTime') or t.get('due', 0))
)
print(f"\n⏰ 逾期待办检查 ({datetime.now().strftime('%Y-%m-%d %H:%M')})")
print('=' * 50)
if not overdue:
print(' ✅ 没有逾期待办,继续保持!')
return
for t in overdue:
title = t.get('subject') or t.get('title', '无标题')
due = t.get('dueTime') or t.get('due')
days = days_overdue(due)
pri = PRIORITY_MAP.get(
int(t.get('priority', 20)), '普通'
)
due_str = datetime.fromtimestamp(
int(due) / 1000
).strftime('%Y-%m-%d')
print(f" 🔴 [{pri}] {title}")
print(f" 截止: {due_str} 逾期: {days}")
print(f"\n合计: {len(overdue)} 条逾期待办")
sys.exit(1 if overdue else 0)
if __name__ == '__main__':
main()
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
上传附件到钉钉 AI 表格 attachment 字段
完整流程(内部自动执行 3 步):
1. dws aitable attachment upload → 获取 uploadUrl + fileToken
2. HTTP PUT 上传文件到 OSS
3. 返回 fileToken,可直接用于 record create/update
用法:
python upload_attachment.py <baseId> <filePath>
输出 (JSON):
{ "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
然后在 record create/update 中使用:
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
"""
import sys
import json
import subprocess
import os
import mimetypes
import re
from pathlib import Path
from typing import Optional, Dict, Any
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def detect_mime_type(file_path: Path) -> str:
"""根据文件扩展名推断 MIME type。"""
mime_type, _ = mimetypes.guess_type(str(file_path))
return mime_type or 'application/octet-stream'
def run_dws(args: list) -> Optional[Dict[str, Any]]:
"""调用 dws 命令并返回解析后的 JSON 结果。"""
cmd = ['dws'] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
print(f"错误:dws 命令失败: {result.stderr.strip()}", file=sys.stderr)
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
print(f"错误:无法解析 dws 响应: {result.stdout[:300]}", file=sys.stderr)
return None
except subprocess.TimeoutExpired:
print('错误:dws 命令超时(60 秒)', file=sys.stderr)
return None
except FileNotFoundError:
print('错误:未找到 dws 命令,请确认已安装并在 PATH 中', file=sys.stderr)
return None
def upload_to_oss(upload_url: str, file_path: Path, mime_type: str) -> bool:
"""通过 HTTP PUT 上传文件到 OSS。"""
file_data = file_path.read_bytes()
req = Request(upload_url, data=file_data, method='PUT')
req.add_header('Content-Type', mime_type)
try:
with urlopen(req, timeout=120) as resp:
if resp.status == 200:
return True
print(f"错误:OSS 上传失败,HTTP {resp.status}", file=sys.stderr)
return False
except HTTPError as e:
print(f"错误:OSS 上传 HTTP 错误 {e.code}: {e.reason}", file=sys.stderr)
return False
except URLError as e:
print(f"错误:OSS 上传网络错误: {e.reason}", file=sys.stderr)
return False
def upload_attachment(base_id: str, file_path_str: str) -> Optional[Dict[str, Any]]:
"""
执行完整的附件上传流程:
1. prepare_attachment_upload → uploadUrl + fileToken
2. PUT 文件到 OSS
3. 返回 fileToken 信息
"""
# 验证文件
file_path = Path(file_path_str).resolve()
if not file_path.exists():
print(f"错误:文件不存在: {file_path}", file=sys.stderr)
return None
if not file_path.is_file():
print(f"错误:不是文件: {file_path}", file=sys.stderr)
return None
file_size = file_path.stat().st_size
if file_size <= 0:
print("错误:文件为空", file=sys.stderr)
return None
if file_size > MAX_FILE_SIZE:
print(f"错误:文件过大 ({file_size:,} 字节,限制 {MAX_FILE_SIZE:,} 字节)", file=sys.stderr)
return None
file_name = file_path.name
mime_type = detect_mime_type(file_path)
# 步骤 1: prepare_attachment_upload
print(f"步骤 1/3: 准备上传 {file_name} ({file_size:,} 字节, {mime_type})...", file=sys.stderr)
dws_args = [
'aitable', 'attachment', 'upload',
'--base-id', base_id,
'--file-name', file_name,
'--size', str(file_size),
'--mime-type', mime_type,
'--format', 'json',
]
result = run_dws(dws_args)
if not result:
return None
status = result.get('status', '')
if status != 'success':
error = result.get('error', {})
print(f"错误:准备上传失败: {error.get('message', json.dumps(error, ensure_ascii=False))}", file=sys.stderr)
return None
data = result.get('data', {})
upload_url = data.get('uploadUrl', '')
file_token = data.get('fileToken', '')
if not upload_url or not file_token:
print(f"错误:返回数据缺少 uploadUrl 或 fileToken: {json.dumps(data, ensure_ascii=False)}", file=sys.stderr)
return None
# 步骤 2: PUT 文件到 OSS
print(f"步骤 2/3: 上传文件到 OSS...", file=sys.stderr)
if not upload_to_oss(upload_url, file_path, mime_type):
return None
# 步骤 3: 返回 fileToken
print(f"步骤 3/3: 上传完成!", file=sys.stderr)
output = {
"fileToken": file_token,
"fileName": file_name,
"size": file_size,
"mimeType": mime_type,
}
return output
def main():
if len(sys.argv) != 3:
print(__doc__)
print('用法:')
print(' python upload_attachment.py <baseId> <filePath>')
print()
print('示例:')
print(' python upload_attachment.py G1DKw2zgV2bEk6PMSBooNxlEVB5r9YAn ./report.pdf')
print()
print('然后在 record create 中使用返回的 fileToken:')
print(' dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \\')
print(' --records \'[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]\' --format json')
sys.exit(1)
base_id = sys.argv[1]
file_path = sys.argv[2]
if not validate_resource_id(base_id):
print('错误:无效的 baseId 格式', file=sys.stderr)
sys.exit(1)
result = upload_attachment(base_id, file_path)
if result is None:
sys.exit(1)
# 正常输出到 stdout(JSON 格式,方便解析)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0)
if __name__ == '__main__':
main()