Skill: jiajiaoy/weather-daily (version 1.0.2)

--- name: weather-daily description: "Daily weather briefing for any city — morning conditions, what to wear, umbrella forecast, evening preview, extreme weather alerts. No API key. Works worldwide." keywords: - 天气 - 今天天气 - 明天天气 - 天气预报 - 本周天气 - 一周天气 - 穿衣建议 - 穿衣指数 - 下雨吗 - 带伞吗 - 空气质量 - AQI - 空气污染 - 极端天气 - 台风 - 暴雨 - 寒潮 - 高温 - 下雪 - 每日推送 - 天气提醒 - weather - forecast - today weather - tomorrow weather - daily weather - weather forecast - weekly forecast - weather alert - air quality - AQI - temperature - rain - snow - typhoon - extreme weather metadata: openclaw: runtime: node: ">=18" tags: - weather - 天气 - 天气预报 - 推送 - daily --- # weather-daily > 私人天气助手 — 早间实况 · 晚间预告 · 一周预报 · 极端预警 ## 何时使用 - 用户说"今天天气""天气怎么样""下雨吗""穿什么" - 用户问"明天天气""明天冷吗""要带伞吗" - 用户说"本周天气""天气预报""这周有雨吗" - 用户问"空气质量""AQI""今天适合出门吗" - 用户说"开启天气推送""订阅天气""每天推天气" - 用户说"下周天气""这周末出去玩合适吗" - 用户说"下个月天气""下个月适合旅游吗" --- ## 📋 功能说明 | 指令 | 脚本 | 说明 | |------|------|------| | 今日天气 | `morning-push.js <userId>` | 今日温度/湿度/风力/分时预报/穿衣/出行建议 | | 明日预告 | `evening-push.js <userId>` | 明日天气预告 + 提醒 + 后天预览 | | 一周预报 | `forecast.js <userId>` | 未来7天逐日天气 + 趋势 + 穿衣建议 | | 下周周报 | `weekly-push.js <userId>` | 每周六推送下周天气 + 最佳出行日 | | 月度概况 | `monthly-push.js <userId>` | 每月末推送下月气候 + 分旬预测 | | 开启推送 | `push-toggle.js on <userId>` | 定时推送（早/晚/周报/月报） | | 关闭推送 | `push-toggle.js off <userId>` | 停止全部推送 | | 推送状态 | `push-toggle.js status <userId>` | 查看当前推送配置 | --- ## 🌤️ 早间天气内容 每日早间推送（默认 07:00），包含： - 今日温度区间、天气状况、湿度、风力风向 - 日出/日落时间 - 分时预报（早晨/上午/下午/夜间） - 空气质量（AQI + 等级） - 穿衣建议、出行建议 - 极端天气提醒 --- ## 🌙 晚间天气内容 每日晚间推送（默认 21:00），包含： - 明日温度、天气状况、湿度、风力 - 出行/穿衣/雨伞等具体提醒 - 极端天气预警（如有） - 后天天气一句话预览 --- ## ⚠️ 支持的预警类型 | 类型 | 说明 | |------|------| | 🌧️ 降雨 | 中雨/大雨/暴雨预警 | | ❄️ 降雪 | 小雪/大雪/暴雪预警 | | 💨 大风 | 6级以上大风预警 | | 🌀 台风 | 台风路径与影响范围 | | 🥶 寒潮 | 大幅降温预警 | | 🔥 高温 | 35°C+ 高温预警 | | 🌫️ 空气质量 | AQI 重度污染预警 | --- ## 🔧 脚本说明 ### 注册用户 ```bash node scripts/register.js <userId> <city> [units] [morningTime] [eveningTime] # 示例： node scripts/register.js alice 上海 node scripts/register.js bob Beijing imperial 08:00 22:00 ``` ### 推送管理 ```bash node scripts/push-toggle.js on <userId> [--morning 07:00] [--evening 21:00] [--channel telegram] node scripts/push-toggle.js off <userId> node scripts/push-toggle.js status <userId> ``` 支持渠道：`telegram` / `feishu` / `slack` / `discord` --- ## ⚠️ 注意事项 1. 使用前须先注册：`node scripts/register.js <userId> <city>` 2. 无需 API Key，天气数据通过 WebSearch 实时搜索获取 3. 用户偏好存储在 `data/users/<userId>.json`，仅含城市/单位/时间等配置，不含天气内容 4. 搜索结果受 WebSearch 实时性限制，极端天气预警以官方气象部门发布为准

path: README.md
content: # Weather Daily — Daily Weather Push Skill

> Morning conditions, evening preview, weekly forecast, extreme weather alerts — no API key needed.

[![clawhub](https://img.shields.io/badge/clawhub-weather--daily-blue)](https://clawhub.ai/skills/weather-daily)
[![openclaw](https://img.shields.io/badge/openclaw-skill-orange)](https://openclaw.ai)

## What it does

Weather Daily delivers a practical daily weather briefing — what to wear this morning, whether to bring an umbrella, what tomorrow looks like, and immediate alerts for extreme conditions. Works for any city worldwide. No API key required — fetches data via real-time web search.

**Morning briefing** — today's conditions, temperature range, UV index, outfit suggestion  
**Evening preview** — tomorrow's forecast + weekly summary  
**Extreme alerts** — typhoon, heavy rain, heatwave, cold snap  
**Global** — works for any city worldwide  
**No API key** — no setup, works out of the box

## Installation

```bash
openclaw install weather-daily
```

## Usage

```bash
# Morning weather briefing
openclaw run weather-daily morning --city "Shanghai"

# Evening preview
openclaw run weather-daily evening --city "London"

# Weekly forecast
openclaw run weather-daily weekly --city "New York"
```

## Keywords

weather · daily weather · weather forecast · weather alert · morning weather · weather briefing · 天气 · 今天天气 · 明天天气 · 天气预报 · 本周天气 · 穿什么 · 极端天气预警 · 台风 · 暴雨

---

Built for [OpenClaw](https://openclaw.ai) · Published on [clawhub.ai](https://clawhub.ai/skills/weather-daily)


path: _meta.json
content: {
  "slug": "weather-daily",
  "version": "1.0.0",
  "name": "每日天气推送",
  "description": "每日天气推送 Skill - 早间今日天气（温度/穿衣/出行建议），晚间明日预告，支持一周预报、极端天气预警。基于城市设置，无需 API Key。",
  "runtime": {
    "node": ">=18"
  },
  "tags": [
    "天气",
    "天气预报",
    "天气推送",
    "每日天气",
    "穿衣建议",
    "空气质量",
    "极端天气",
    "weather",
    "forecast"
  ],
  "author": "jiajiaoy",
  "license": "MIT",
  "homepage": "https://github.com/jiajiaoy/weather-daily"
}


path: package.json
content: {
  "name": "weather-daily",
  "version": "1.0.2",
  "description": "Daily weather briefing for any city — morning conditions, what to wear, umbrella forecast, evening preview, extreme weather alerts. No API key. Works worldwide.",
  "keywords": [
    "天气",
    "今天天气",
    "明天天气",
    "天气预报",
    "本周天气",
    "本周天气预报",
    "穿衣建议",
    "穿衣指数",
    "空气质量",
    "AQI",
    "空气污染",
    "极端天气",
    "暴雨",
    "台风",
    "下雪",
    "每日推送",
    "天气推送",
    "早晨推送",
    "晚间推送",
    "明日天气",
    "weather",
    "forecast",
    "daily weather",
    "weather forecast",
    "today weather",
    "tomorrow weather",
    "weekly forecast",
    "weather alert",
    "weather push",
    "air quality",
    "AQI",
    "temperature",
    "rain",
    "snow",
    "typhoon"
  ],
  "author": "jiajiaoy",
  "license": "MIT",
  "scripts": {
    "morning": "node scripts/morning-push.js",
    "evening": "node scripts/evening-push.js",
    "forecast": "node scripts/forecast.js",
    "push-on": "node scripts/push-toggle.js on",
    "push-off": "node scripts/push-toggle.js off",
    "push-status": "node scripts/push-toggle.js status"
  }
}

path: scripts/evening-push.js
content: #!/usr/bin/env node
/**
 * weather-daily — evening weather push prompt generator (tomorrow preview)
 * Driven by openclaw cron; output is fulfilled by Claude via WebSearch
 *
 * Usage:
 *   node evening-push.js <userId>
 */

const fs = require('fs');
const path = require('path');

const USERS_DIR = path.join(__dirname, '../data/users');

function sanitizeId(value) {
  if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) {
    console.error('❌ Invalid userId');
    process.exit(1);
  }
  return value;
}

function safeUserPath(userId) {
  const resolved = path.resolve(USERS_DIR, `${userId}.json`);
  if (!resolved.startsWith(path.resolve(USERS_DIR) + path.sep)) {
    console.error('❌ Illegal path');
    process.exit(1);
  }
  return resolved;
}

function loadUser(userId) {
  const f = safeUserPath(userId);
  if (!fs.existsSync(f)) {
    console.error(`❌ User ${userId} not found. Run: node register.js ${userId} <city>`);
    process.exit(1);
  }
  return JSON.parse(fs.readFileSync(f, 'utf8'));
}

const args = process.argv.slice(2);
if (!args[0]) {
  console.error('Usage: node evening-push.js <userId>');
  process.exit(1);
}

const userId = sanitizeId(args[0]);
const user   = loadUser(userId);

const city  = user.city  || '上海';
const units = user.units || 'metric';
const unit  = units === 'metric' ? '°C' : '°F';
const lang  = user.language || ((/[\u4e00-\u9fa5]/.test(city)) ? 'zh' : 'en');

const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);

const tmYear  = tomorrow.getFullYear();
const tmMonth = tomorrow.getMonth() + 1;
const tmDay   = tomorrow.getDate();
const tomorrowISO = `${tmYear}-${String(tmMonth).padStart(2,'0')}-${String(tmDay).padStart(2,'0')}`;

if (lang === 'zh') {
  const WEEKDAYS_ZH = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六'];
  const todayDisplay    = `${now.getFullYear()}年${now.getMonth()+1}月${now.getDate()}日 ${WEEKDAYS_ZH[now.getDay()]}`;
  const tomorrowDisplay = `${tmYear}年${tmMonth}月${tmDay}日 ${WEEKDAYS_ZH[tomorrow.getDay()]}`;

  console.log(`请为用户查询 ${city} 明天（${tomorrowDisplay}）的天气预报，并按以下格式推送晚间天气预告。

搜索步骤：
1. 搜索「${city} 明天天气 ${tomorrowISO}」
2. 搜索「${city} 未来天气 是否有极端天气」

输出格式：
🌙 ${city} 明日天气预告 · ${todayDisplay}晚间
━━━━━━━━━━━━━━━━━━━━━━━
📅 明天 ${tomorrowDisplay}

🌡️ 预计温度：低温X${unit} ~ 高温X${unit}
☁️ 天气：[天气状况]
💧 湿度：X%  🌬️ 风力：X级

⏰ 明日提醒
[根据天气提供出行/穿衣/携带雨伞等具体建议]

⚠️ 重要预警：[如有极端天气则高亮提示，无则省略]

🗓️ 后天预览：[一句话说明后天大致天气]

💡 回复"本周预报"查看7天天气`);
} else {
  const WEEKDAYS_EN = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
  const MONTHS_EN = ['January','February','March','April','May','June','July','August','September','October','November','December'];
  const todayDisplay    = `${WEEKDAYS_EN[now.getDay()]}, ${MONTHS_EN[now.getMonth()]} ${now.getDate()}, ${now.getFullYear()}`;
  const tomorrowDisplay = `${WEEKDAYS_EN[tomorrow.getDay()]}, ${MONTHS_EN[tomorrow.getMonth()]} ${tmDay}, ${tmYear}`;

  console.log(`Please search for tomorrow's weather in ${city} (${tomorrowDisplay}) and send the evening preview in the following format.

Search steps:
1. Search "${city} weather tomorrow ${tomorrowISO}"
2. Search "${city} weather outlook extreme weather warning"

Output format:
🌙 ${city} Tomorrow's Weather Preview · ${todayDisplay} Evening
━━━━━━━━━━━━━━━━━━━━━━━
📅 Tomorrow: ${tomorrowDisplay}

🌡️ Temperature: Low X${unit} ~ High X${unit}
☁️ Conditions: [weather description]
💧 Humidity: X%  🌬️ Wind: [speed & direction]

⏰ Tomorrow's reminders
[Specific commute / outfit / umbrella advice based on the forecast]

⚠️ Weather alert: [highlight any extreme weather warnings; omit if none]

🗓️ Day after tomorrow: [one-line outlook]

💡 Reply "forecast" for the 7-day outlook`);
}


path: scripts/forecast.js
content: #!/usr/bin/env node
/**
 * weather-daily — 7-day forecast prompt generator
 * Output is fulfilled by Claude via WebSearch
 *
 * Usage:
 *   node forecast.js <userId>
 */

const fs = require('fs');
const path = require('path');

const USERS_DIR = path.join(__dirname, '../data/users');

function sanitizeId(value) {
  if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) {
    console.error('❌ Invalid userId');
    process.exit(1);
  }
  return value;
}

function safeUserPath(userId) {
  const resolved = path.resolve(USERS_DIR, `${userId}.json`);
  if (!resolved.startsWith(path.resolve(USERS_DIR) + path.sep)) {
    console.error('❌ Illegal path');
    process.exit(1);
  }
  return resolved;
}

function loadUser(userId) {
  const f = safeUserPath(userId);
  if (!fs.existsSync(f)) {
    console.error(`❌ User ${userId} not found. Run: node register.js ${userId} <city>`);
    process.exit(1);
  }
  return JSON.parse(fs.readFileSync(f, 'utf8'));
}

const args = process.argv.slice(2);
if (!args[0]) {
  console.error('Usage: node forecast.js <userId>');
  process.exit(1);
}

const userId = sanitizeId(args[0]);
const user   = loadUser(userId);

const city  = user.city  || '上海';
const units = user.units || 'metric';
const unit  = units === 'metric' ? 'C' : 'F';
const lang  = user.language || ((/[\u4e00-\u9fa5]/.test(city)) ? 'zh' : 'en');

const now = new Date();
const year  = now.getFullYear();
const month = now.getMonth() + 1;
const day   = now.getDate();

if (lang === 'zh') {
  const WEEKDAYS_ZH = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六'];
  const todayDisplay = `${year}年${month}月${day}日（${WEEKDAYS_ZH[now.getDay()]}）`;

  console.log(`请为用户查询 ${city} 未来7天的天气预报（从 ${todayDisplay} 起），并按以下格式输出。

搜索步骤：
1. 搜索「${city} 未来7天天气预报」
2. 搜索「${city} 本周天气」

输出格式：
📅 ${city} 一周天气预报
━━━━━━━━━━━━━━━━━━━━━━━
从 ${todayDisplay} 起，未来7天：

[每天一行]
{星期} {日期}  {天气图标} {天气}  🌡️{低温}~{高温}°${unit}  💧{湿度}%  🌬️{风力}

━━━━━━━━━━━━━━━━━━━━━━━
📌 本周趋势：[整体天气趋势描述，2-3句]
⚠️ 重要提醒：[本周内任何极端天气预警]
👔 本周穿衣趋势：[整体穿衣建议]

💡 回复"今天天气"获取今日详细天气`);
} else {
  const WEEKDAYS_EN = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
  const MONTHS_EN = ['January','February','March','April','May','June','July','August','September','October','November','December'];
  const todayDisplay = `${WEEKDAYS_EN[now.getDay()]}, ${MONTHS_EN[now.getMonth()]} ${day}, ${year}`;

  console.log(`Please search for the 7-day weather forecast for ${city} starting from ${todayDisplay} and present it in the following format.

Search steps:
1. Search "${city} 7-day weather forecast"
2. Search "${city} weather this week"

Output format:
📅 ${city} 7-Day Weather Forecast
━━━━━━━━━━━━━━━━━━━━━━━
Starting ${todayDisplay}:

[One line per day]
{Day} {Date}  {icon} {conditions}  🌡️{low}~{high}°${unit}  💧{humidity}%  🌬️{wind}

━━━━━━━━━━━━━━━━━━━━━━━
📌 Weekly trend: [2–3 sentences on overall weather pattern]
⚠️ Alerts: [any extreme weather warnings this week]
👔 What to wear this week: [general outfit guidance]

💡 Reply "today's weather" for today's detailed report`);
}


path: scripts/monthly-push.js
content: #!/usr/bin/env node
/**
 * weather-daily — next-month weather overview prompt generator (sent at month end)
 * Runs on 28–31 of each month; script checks internally whether today is the last day.
 *
 * Usage:
 *   node monthly-push.js <userId>
 *   node monthly-push.js <userId> --force   # skip last-day check
 */

const fs = require('fs');
const path = require('path');

const USERS_DIR = path.join(__dirname, '../data/users');

function sanitizeId(value) {
  if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) {
    console.error('❌ Invalid userId');
    process.exit(1);
  }
  return value;
}

function safeUserPath(userId) {
  const resolved = path.resolve(USERS_DIR, `${userId}.json`);
  if (!resolved.startsWith(path.resolve(USERS_DIR) + path.sep)) {
    console.error('❌ Illegal path');
    process.exit(1);
  }
  return resolved;
}

function loadUser(userId) {
  const f = safeUserPath(userId);
  if (!fs.existsSync(f)) {
    console.error(`❌ User ${userId} not found. Run: node register.js ${userId} <city>`);
    process.exit(1);
  }
  return JSON.parse(fs.readFileSync(f, 'utf8'));
}

function isLastDayOfMonth(date) {
  const tomorrow = new Date(date);
  tomorrow.setDate(date.getDate() + 1);
  return tomorrow.getDate() === 1;
}

const args = process.argv.slice(2);
if (!args[0]) {
  console.error('Usage: node monthly-push.js <userId> [--force]');
  process.exit(1);
}

const userId = sanitizeId(args[0]);
const force  = args.includes('--force');
const now    = new Date();

if (!force && !isLastDayOfMonth(now)) {
  process.exit(0);
}

const user = loadUser(userId);

const city  = user.city  || '上海';
const units = user.units || 'metric';
const unit  = units === 'metric' ? 'C' : 'F';
const lang  = user.language || ((/[\u4e00-\u9fa5]/.test(city)) ? 'zh' : 'en');

const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const nextYear  = nextMonth.getFullYear();
const nextMon   = nextMonth.getMonth() + 1;

if (lang === 'zh') {
  const SEASON = ['冬季','冬季','春季','春季','春季','夏季','夏季','夏季','秋季','秋季','秋季','冬季'];
  const season = SEASON[nextMon - 1];

  console.log(`请为用户查询 ${city} ${nextYear}年${nextMon}月的天气概况，并按以下格式推送月度天气预报。

搜索步骤：
1. 搜索「${city} ${nextYear}年${nextMon}月天气预报」
2. 搜索「${city} ${nextMon}月气候特点」
3. 搜索「${city} ${nextMon}月份${season}注意事项」

输出格式：
🗓️ ${city} ${nextYear}年${nextMon}月天气月报
━━━━━━━━━━━━━━━━━━━━━━━
📊 气温概况
  · 平均气温：X°${unit}（历史同期：X°${unit}）
  · 最高气温：X°${unit}  最低气温：X°${unit}
  · 温差提示：[早晚温差说明]

🌧️ 降水情况
  · 降雨概率：X%
  · 主要降水时段：[上/中/下旬]
  · 是否有梅雨/台风/干旱等气候特征

💨 主要天气特征
  [本月主要天气类型及成因，3-5句]

📅 分旬预测
  🔹 上旬（1-10日）：[天气趋势]
  🔹 中旬（11-20日）：[天气趋势]
  🔹 下旬（21日-月末）：[天气趋势]

⚠️ 本月预警
  [本月可能出现的极端天气或气候风险，无则省略]

👔 穿衣指南
  [本月整体穿衣建议，按上中下旬温度变化说明]

🌿 生活建议
  [本月养生、出行、运动等实用建议，2-3条]

💡 回复"本周天气"获取详细一周预报`);
} else {
  const MONTHS_EN = ['January','February','March','April','May','June','July','August','September','October','November','December'];
  const SEASON_EN = ['Winter','Winter','Spring','Spring','Spring','Summer','Summer','Summer','Autumn','Autumn','Autumn','Winter'];
  const monthName = MONTHS_EN[nextMon - 1];
  const season    = SEASON_EN[nextMon - 1];

  console.log(`Please search for the weather overview for ${city} in ${monthName} ${nextYear} and send the monthly weather report in the following format.

Search steps:
1. Search "${city} weather forecast ${monthName} ${nextYear}"
2. Search "${city} ${monthName} climate"
3. Search "${city} ${season} ${monthName} weather tips"

Output format:
🗓️ ${city} ${monthName} ${nextYear} Weather Monthly Report
━━━━━━━━━━━━━━━━━━━━━━━
📊 Temperature Overview
  · Average temperature: X°${unit} (historical average: X°${unit})
  · High: X°${unit}  Low: X°${unit}
  · Day-night swing: [notes on temperature range]

🌧️ Precipitation
  · Rain probability: X%
  · Main rainy periods: [early / mid / late month]
  · Notable climate patterns: [e.g. monsoon, drought, snow season]

💨 Weather Characteristics
  [3–5 sentences on dominant weather types and causes this month]

📅 10-Day Periods
  🔹 Early month (1–10): [trend]
  🔹 Mid month (11–20): [trend]
  🔹 Late month (21–end): [trend]

⚠️ Monthly alerts
  [Potential extreme weather or climate risks; omit if none]

👔 What to wear
  [General outfit guidance across the month's temperature changes]

🌿 Lifestyle tips
  [2–3 practical tips for health, travel, or outdoor activities]

💡 Reply "forecast" for the detailed 7-day outlook`);
}


path: scripts/morning-push.js
content: #!/usr/bin/env node
/**
 * weather-daily — morning weather push prompt generator
 * Driven by openclaw cron; output is fulfilled by Claude via WebSearch
 *
 * Usage:
 *   node morning-push.js <userId>
 */

const fs = require('fs');
const path = require('path');

const USERS_DIR = path.join(__dirname, '../data/users');

function sanitizeId(value) {
  if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) {
    console.error('❌ Invalid userId');
    process.exit(1);
  }
  return value;
}

function safeUserPath(userId) {
  const resolved = path.resolve(USERS_DIR, `${userId}.json`);
  if (!resolved.startsWith(path.resolve(USERS_DIR) + path.sep)) {
    console.error('❌ Illegal path');
    process.exit(1);
  }
  return resolved;
}

function loadUser(userId) {
  const f = safeUserPath(userId);
  if (!fs.existsSync(f)) {
    console.error(`❌ User ${userId} not found. Run: node register.js ${userId} <city>`);
    process.exit(1);
  }
  return JSON.parse(fs.readFileSync(f, 'utf8'));
}

const args = process.argv.slice(2);
if (!args[0]) {
  console.error('Usage: node morning-push.js <userId>');
  process.exit(1);
}

const userId = sanitizeId(args[0]);
const user   = loadUser(userId);

const city  = user.city  || '上海';
const units = user.units || 'metric';
const unit  = units === 'metric' ? '°C' : '°F';
const lang  = user.language || ((/[\u4e00-\u9fa5]/.test(city)) ? 'zh' : 'en');

const now = new Date();
const year  = now.getFullYear();
const month = now.getMonth() + 1;
const day   = now.getDate();
const dateISO = `${year}-${String(month).padStart(2,'0')}-${String(day).padStart(2,'0')}`;

if (lang === 'zh') {
  const WEEKDAYS_ZH = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六'];
  const weekday = WEEKDAYS_ZH[now.getDay()];
  const dateDisplay = `${year}年${month}月${day}日 ${weekday}`;

  console.log(`请为用户查询 ${city} 今天（${dateDisplay}）的天气，并按以下格式推送早间天气报告。

搜索步骤：
1. 搜索「${city} 今天天气 ${dateISO}」
2. 搜索「${city} 今日空气质量」
3. 如有极端天气预警，搜索「${city} 天气预警」

输出格式：
🌤️ ${city} 早间天气 · ${dateDisplay}
━━━━━━━━━━━━━━━━━━━━━━━
🌡️ 温度：低温X${unit} ~ 高温X${unit}
☁️ 天气：[天气状况]
💧 湿度：X%  🌬️ 风力：X级（风向）
🌅 日出：XX:XX  🌇 日落：XX:XX

📊 分时预报
🌅 早晨(6-9点)：[温度+状况]
☀️ 上午(9-12点)：[温度+状况]
🌤️ 下午(12-18点)：[温度+状况]
🌙 夜间(18-24点)：[温度+状况]

🌍 空气质量：AQI X（[等级]）[提示]

🎽 穿衣建议：[根据温度给出具体建议]
🚗 出行建议：[根据天气给出出行提示]

⚠️ 今日提醒：[重要天气注意事项，无则省略]

💡 查看本周预报：回复"天气预报"`);
} else {
  const WEEKDAYS_EN = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
  const MONTHS_EN = ['January','February','March','April','May','June','July','August','September','October','November','December'];
  const weekday = WEEKDAYS_EN[now.getDay()];
  const dateDisplay = `${weekday}, ${MONTHS_EN[now.getMonth()]} ${day}, ${year}`;

  console.log(`Please search for today's weather in ${city} (${dateDisplay}) and send the morning weather report in the following format.

Search steps:
1. Search "${city} weather today ${dateISO}"
2. Search "${city} air quality today"
3. If extreme weather is possible, search "${city} weather warning"

Output format:
🌤️ ${city} Morning Weather · ${dateDisplay}
━━━━━━━━━━━━━━━━━━━━━━━
🌡️ Temperature: Low X${unit} ~ High X${unit}
☁️ Conditions: [weather description]
💧 Humidity: X%  🌬️ Wind: [speed & direction]
🌅 Sunrise: XX:XX  🌇 Sunset: XX:XX

📊 Hourly Forecast
🌅 Morning (6–9 AM): [temp + conditions]
☀️ Late Morning (9 AM–12 PM): [temp + conditions]
🌤️ Afternoon (12–6 PM): [temp + conditions]
🌙 Evening (6 PM–midnight): [temp + conditions]

🌍 Air Quality: AQI X ([level]) [note]

🎽 What to wear: [outfit suggestion based on temperature]
🚗 Commute tip: [travel advice based on conditions]

⚠️ Today's alert: [important weather warnings, omit if none]

💡 Reply "forecast" for the weekly outlook`);
}


path: scripts/push-toggle.js
content: #!/usr/bin/env node
/**
 * weather-daily — 推送开关
 *
 * 用法:
 *   node push-toggle.js on <userId>   开启推送
 *   node push-toggle.js off <userId>  关闭推送
 *   node push-toggle.js status <userId>  查看状态
 *
 * 选项:
 *   --morning HH:MM   早报时间（覆盖用户设置，默认 07:00）
 *   --evening HH:MM   晚报时间（覆盖用户设置，默认 21:00）
 *   --channel <name>  推送渠道（默认 telegram）
 */

const fs = require('fs');
const path = require('path');

const USERS_DIR = path.join(__dirname, '../data/users');

// 只允许字母、数字、连字符、下划线，最长 128 字符
function sanitizeId(value, label) {
  if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) {
    console.error(`❌ 无效的 ${label}：只允许字母、数字、- 和 _，长度 1-128`);
    process.exit(1);
  }
  return value;
}

// 校验 HH:MM 格式，返回 { h, m } 整数
function sanitizeTime(value, label) {
  if (typeof value !== 'string' || !/^\d{1,2}:\d{2}$/.test(value)) {
    console.error(`❌ 无效的 ${label}：格式应为 HH:MM，如 07:00`);
    process.exit(1);
  }
  const [h, m] = value.split(':').map(Number);
  if (h < 0 || h > 23 || m < 0 || m > 59) {
    console.error(`❌ 无效的 ${label}：小时 0-23，分钟 0-59`);
    process.exit(1);
  }
  return { h, m };
}

// 验证文件路径确实在 USERS_DIR 内（防路径穿越）
function safeUserPath(userId) {
  const resolved = path.resolve(USERS_DIR, `${userId}.json`);
  if (!resolved.startsWith(path.resolve(USERS_DIR) + path.sep)) {
    console.error('❌ 非法路径');
    process.exit(1);
  }
  return resolved;
}

function loadUser(userId) {
  const f = safeUserPath(userId);
  if (!fs.existsSync(f)) return null;
  return JSON.parse(fs.readFileSync(f, 'utf8'));
}

function saveUser(userId, data) {
  fs.mkdirSync(USERS_DIR, { recursive: true });
  fs.writeFileSync(safeUserPath(userId), JSON.stringify(data, null, 2), 'utf8');
}

const ALLOWED_CHANNELS = new Set(['telegram', 'feishu', 'slack', 'discord']);

function enablePush(userId, opts = {}) {
  userId = sanitizeId(userId, 'userId');

  // 加载用户资料获取默认推送时间
  const userProfile = loadUser(userId);
  const defaultMorning = (userProfile && userProfile.preferences && userProfile.preferences.morningTime) || '07:00';
  const defaultEvening = (userProfile && userProfile.preferences && userProfile.preferences.eveningTime) || '21:00';

  const { h: mh, m: mm } = sanitizeTime(opts.morning || defaultMorning, 'morning');
  const { h: eh, m: em } = sanitizeTime(opts.evening || defaultEvening, 'evening');

  const rawChannel = opts.channel || (userProfile && userProfile.preferences && userProfile.preferences.channel) || 'telegram';
  if (!ALLOWED_CHANNELS.has(rawChannel)) {
    console.error(`❌ 不支持的渠道：${rawChannel}。支持：${[...ALLOWED_CHANNELS].join(', ')}`);
    process.exit(1);
  }
  const channel = rawChannel;

  const morningCron = `${mm} ${mh} * * *`;
  const eveningCron = `${em} ${eh} * * *`;

  const sessionKey = `agent:main:${channel}:direct:${userId}`;

  // 早间天气 cron
  const morningConfig = {
    name: `weather-morning-${userId}`,
    cronExpr: morningCron,
    tz: 'Asia/Shanghai',
    session: 'isolated',
    sessionKey,
    channel,
    to: userId,
    announce: true,
    timeoutSeconds: 120,
    message: `node ${path.join(__dirname, 'morning-push.js')} ${userId}`
  };
  console.log(`__OPENCLAW_CRON_ADD__:${JSON.stringify(morningConfig)}`);

  // 晚间预告 cron
  const eveningConfig = {
    name: `weather-evening-${userId}`,
    cronExpr: eveningCron,
    tz: 'Asia/Shanghai',
    session: 'isolated',
    sessionKey,
    channel,
    to: userId,
    announce: true,
    timeoutSeconds: 120,
    message: `node ${path.join(__dirname, 'evening-push.js')} ${userId}`
  };
  console.log(`__OPENCLAW_CRON_ADD__:${JSON.stringify(eveningConfig)}`);

  // 周末下周天气周报 cron（每周六 20:00）
  const weeklyConfig = {
    name: `weather-weekly-${userId}`,
    cronExpr: '0 20 * * 6',
    tz: 'Asia/Shanghai',
    session: 'isolated',
    sessionKey,
    channel,
    to: userId,
    announce: true,
    timeoutSeconds: 120,
    message: `node ${path.join(__dirname, 'weekly-push.js')} ${userId}`
  };
  console.log(`__OPENCLAW_CRON_ADD__:${JSON.stringify(weeklyConfig)}`);

  // 月末下月天气概览 cron（每月 28-31 日 20:00，脚本内判断是否月末）
  const monthlyConfig = {
    name: `weather-monthly-${userId}`,
    cronExpr: '0 20 28-31 * *',
    tz: 'Asia/Shanghai',
    session: 'isolated',
    sessionKey,
    channel,
    to: userId,
    announce: true,
    timeoutSeconds: 120,
    message: `node ${path.join(__dirname, 'monthly-push.js')} ${userId}`
  };
  console.log(`__OPENCLAW_CRON_ADD__:${JSON.stringify(monthlyConfig)}`);

  const morningDisplay = `${String(mh).padStart(2,'0')}:${String(mm).padStart(2,'0')}`;
  const eveningDisplay = `${String(eh).padStart(2,'0')}:${String(em).padStart(2,'0')}`;

  // 更新用户资料中的推送设置
  const updatedProfile = userProfile ? {
    ...userProfile,
    preferences: {
      ...userProfile.preferences,
      morningTime: morningDisplay,
      eveningTime: eveningDisplay,
      channel,
      pushEnabled: true
    },
    pushEnabledAt: new Date().toISOString()
  } : {
    userId,
    preferences: {
      morningTime: morningDisplay,
      eveningTime: eveningDisplay,
      channel,
      pushEnabled: true
    },
    pushEnabledAt: new Date().toISOString()
  };

  saveUser(userId, updatedProfile);

  const city = (updatedProfile.city) || '（未设置）';

  console.log(`
✅ 天气推送已开启

🌆 城市：${city}
⏰ 早间推送：每天 ${morningDisplay}（今日天气）
🌙 晚间推送：每天 ${eveningDisplay}（明日预告）
📅 周报推送：每周六 20:00（下周天气）
🗓️ 月报推送：每月末 20:00（下月概况）
📡 渠道：${channel}

关闭推送：node push-toggle.js off ${userId}`);
}

function disablePush(userId) {
  userId = sanitizeId(userId, 'userId');
  const user = loadUser(userId);
  if (!user) {
    console.log(`❌ 未找到用户 ${userId} 的推送记录`);
    return;
  }

  console.log(`__OPENCLAW_CRON_RM__:weather-morning-${userId}`);
  console.log(`__OPENCLAW_CRON_RM__:weather-evening-${userId}`);
  console.log(`__OPENCLAW_CRON_RM__:weather-weekly-${userId}`);
  console.log(`__OPENCLAW_CRON_RM__:weather-monthly-${userId}`);

  const updated = {
    ...user,
    preferences: {
      ...user.preferences,
      pushEnabled: false
    },
    pushDisabledAt: new Date().toISOString()
  };
  saveUser(userId, updated);
  console.log(`✅ 天气推送已关闭`);
}

function showStatus(userId) {
  userId = sanitizeId(userId, 'userId');
  const user = loadUser(userId);
  if (!user) {
    console.log(`❌ 未找到用户 ${userId} 的推送记录`);
    return;
  }

  const prefs = user.preferences || {};
  const city         = user.city || '（未设置）';
  const pushEnabled  = prefs.pushEnabled || false;
  const morningTime  = prefs.morningTime || '07:00';
  const eveningTime  = prefs.eveningTime || '21:00';
  const channel      = prefs.channel || 'telegram';
  const enabledAt    = user.pushEnabledAt ? user.pushEnabledAt.split('T')[0] : '未知';

  console.log(`
📡 推送状态 — ${userId}
━━━━━━━━━━━━━━━━━━━━━━━
城市：${city}
状态：${pushEnabled ? '✅ 开启中' : '❌ 已关闭'}
早间推送：${morningTime}（今日天气）
晚间推送：${eveningTime}（明日预告）
周报推送：每周六 20:00（下周天气）
月报推送：每月末 20:00（下月概况）
渠道：${channel}
开启于：${enabledAt}
━━━━━━━━━━━━━━━━━━━━━━━`);
}

module.exports = { enablePush, disablePush, showStatus };

if (require.main !== module) return;

const args = process.argv.slice(2);
const command = args[0];
const userId  = args[1];

if (!command || !userId) {
  console.log(`用法:
  node push-toggle.js on <userId> [--morning 07:00] [--evening 21:00] [--channel telegram]
  node push-toggle.js off <userId>
  node push-toggle.js status <userId>`);
  process.exit(1);
}

const opts = {};
const mi = args.indexOf('--morning');
if (mi !== -1) opts.morning = args[mi + 1];
const ei = args.indexOf('--evening');
if (ei !== -1) opts.evening = args[ei + 1];
const ci = args.indexOf('--channel');
if (ci !== -1) opts.channel = args[ci + 1];

switch (command) {
  case 'on':     enablePush(userId, opts); break;
  case 'off':    disablePush(userId); break;
  case 'status': showStatus(userId); break;
  default:
    console.log(`❌ 未知命令: ${command}`);
    process.exit(1);
}


path: scripts/register.js
content: #!/usr/bin/env node
/**
 * weather-daily — user registration / city setup
 *
 * Usage:
 *   node register.js <userId> <city> [units] [morningTime] [eveningTime] [language] [timezone]
 *
 * Parameters:
 *   userId      required, letters/digits/-/_, 1-128 chars
 *   city        required, 1-50 chars, supports Chinese/English/digits/spaces/hyphens
 *   units       optional, metric (default) or imperial
 *   morningTime optional, HH:MM format (default 07:00)
 *   eveningTime optional, HH:MM format (default 21:00)
 *   language    optional, zh or en (auto-detected from city name if omitted)
 *   timezone    optional, IANA timezone (e.g. America/New_York; default: Asia/Shanghai for zh, UTC for en)
 *
 * Examples:
 *   node register.js alice 上海
 *   node register.js bob "New York" imperial 08:00 22:00 en America/New_York
 *   node register.js carol London metric 07:00 21:00 en Europe/London
 */

const fs = require('fs');
const path = require('path');

const USERS_DIR = path.join(__dirname, '../data/users');

function sanitizeId(value) {
  if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) {
    console.error('❌ Invalid userId: only letters, digits, - and _ are allowed (1-128 chars)');
    process.exit(1);
  }
  return value;
}

function sanitizeCity(value) {
  if (typeof value !== 'string') {
    console.error('❌ Invalid city name');
    process.exit(1);
  }
  const stripped = value.replace(/[^\u4e00-\u9fa5a-zA-Z0-9\s\-]/g, '').trim();
  if (!/^[\u4e00-\u9fa5a-zA-Z0-9\s\-]{1,50}$/.test(stripped)) {
    console.error('❌ Invalid city name: use Chinese/English/digits/spaces/hyphens, length 1-50');
    process.exit(1);
  }
  return stripped;
}

function sanitizeUnits(value) {
  if (value !== 'metric' && value !== 'imperial') {
    console.error('❌ Invalid units: use metric or imperial');
    process.exit(1);
  }
  return value;
}

function sanitizeTime(value, label) {
  if (typeof value !== 'string' || !/^\d{1,2}:\d{2}$/.test(value)) {
    console.error(`❌ Invalid ${label}: format should be HH:MM, e.g. 07:00`);
    process.exit(1);
  }
  const [h, m] = value.split(':').map(Number);
  if (h < 0 || h > 23 || m < 0 || m > 59) {
    console.error(`❌ Invalid ${label}: hour 0-23, minute 0-59`);
    process.exit(1);
  }
  return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}

function sanitizeLanguage(value) {
  if (value !== 'zh' && value !== 'en') {
    console.error('❌ Invalid language: use zh or en');
    process.exit(1);
  }
  return value;
}

// Simple IANA timezone format check (not exhaustive, prevents injection)
function sanitizeTimezone(value) {
  if (typeof value !== 'string' || !/^[A-Za-z][A-Za-z0-9_+\-\/]{0,49}$/.test(value)) {
    console.error('❌ Invalid timezone: use IANA format, e.g. America/New_York');
    process.exit(1);
  }
  return value;
}

function safeUserPath(userId) {
  const resolved = path.resolve(USERS_DIR, `${userId}.json`);
  if (!resolved.startsWith(path.resolve(USERS_DIR) + path.sep)) {
    console.error('❌ Illegal path');
    process.exit(1);
  }
  return resolved;
}

// Auto-detect language from city name: Chinese chars → zh, else → en
function detectLanguage(city) {
  return /[\u4e00-\u9fa5]/.test(city) ? 'zh' : 'en';
}

// --- Main ---
const args = process.argv.slice(2);

if (args.length < 2) {
  console.log(`Usage:
  node register.js <userId> <city> [units] [morningTime] [eveningTime] [language] [timezone]

Parameters:
  userId      letters/digits/-/_, 1-128 chars
  city        city name, supports Chinese/English (e.g. 上海, Beijing, New York)
  units       metric (default, °C) or imperial (°F)
  morningTime HH:MM format, morning push time (default 07:00)
  eveningTime HH:MM format, evening push time (default 21:00)
  language    zh or en (auto-detected from city name if omitted)
  timezone    IANA timezone (default: Asia/Shanghai for zh, UTC for en)

Examples:
  node register.js alice 上海
  node register.js bob "New York" imperial 08:00 22:00 en America/New_York
  node register.js carol London metric 07:00 21:00 en Europe/London`);
  process.exit(1);
}

const userId      = sanitizeId(args[0]);
const city        = sanitizeCity(args[1]);
const units       = sanitizeUnits(args[2] || 'metric');
const morningTime = sanitizeTime(args[3] || '07:00', 'morningTime');
const eveningTime = sanitizeTime(args[4] || '21:00', 'eveningTime');
const language    = args[5] ? sanitizeLanguage(args[5]) : detectLanguage(city);
const defaultTz   = language === 'zh' ? 'Asia/Shanghai' : 'UTC';
const timezone    = args[6] ? sanitizeTimezone(args[6]) : defaultTz;

fs.mkdirSync(USERS_DIR, { recursive: true });

const filePath = safeUserPath(userId);
const now = new Date().toISOString();

let createdAt = now;
if (fs.existsSync(filePath)) {
  try {
    const existing = JSON.parse(fs.readFileSync(filePath, 'utf8'));
    if (existing.createdAt) createdAt = existing.createdAt;
  } catch (_) {}
}

const profile = {
  userId,
  city,
  units,
  language,
  preferences: {
    morningTime,
    eveningTime,
    timezone,
    channel: 'telegram',
    pushEnabled: false,
    alerts: {
      rain: true,
      snow: true,
      wind: true,
      extreme: true,
      airQuality: true
    }
  },
  createdAt,
  updatedAt: now
};

fs.writeFileSync(filePath, JSON.stringify(profile, null, 2), 'utf8');

const unitLabel = units === 'metric' ? '°C / metric' : '°F / imperial';

if (language === 'zh') {
  console.log(`
✅ 用户注册成功

👤 用户：${userId}
🌆 城市：${city}
🌡️ 单位：${unitLabel}
🌐 语言：中文
⏰ 早间推送：${morningTime}（今日天气）
🌙 晚间推送：${eveningTime}（明日预告）
🕐 时区：${timezone}

下一步：
  开启每日推送：node scripts/push-toggle.js on ${userId}
  查看今日天气：node scripts/morning-push.js ${userId}
  查看一周预报：node scripts/forecast.js ${userId}
  修改城市设置：node scripts/register.js ${userId} <新城市>`);
} else {
  console.log(`
✅ Registration successful

👤 User: ${userId}
🌆 City: ${city}
🌡️ Units: ${unitLabel}
🌐 Language: English
⏰ Morning push: ${morningTime} (today's weather)
🌙 Evening push: ${eveningTime} (tomorrow's preview)
🕐 Timezone: ${timezone}

Next steps:
  Enable daily push: node scripts/push-toggle.js on ${userId}
  Today's weather:   node scripts/morning-push.js ${userId}
  Weekly forecast:   node scripts/forecast.js ${userId}
  Change city:       node scripts/register.js ${userId} <new-city>`);
}


path: scripts/weekly-push.js
content: #!/usr/bin/env node
/**
 * weather-daily — next-week forecast prompt generator (sent every weekend)
 * Output is fulfilled by Claude via WebSearch
 *
 * Usage:
 *   node weekly-push.js <userId>
 */

const fs = require('fs');
const path = require('path');

const USERS_DIR = path.join(__dirname, '../data/users');

function sanitizeId(value) {
  if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,128}$/.test(value)) {
    console.error('❌ Invalid userId');
    process.exit(1);
  }
  return value;
}

function safeUserPath(userId) {
  const resolved = path.resolve(USERS_DIR, `${userId}.json`);
  if (!resolved.startsWith(path.resolve(USERS_DIR) + path.sep)) {
    console.error('❌ Illegal path');
    process.exit(1);
  }
  return resolved;
}

function loadUser(userId) {
  const f = safeUserPath(userId);
  if (!fs.existsSync(f)) {
    console.error(`❌ User ${userId} not found. Run: node register.js ${userId} <city>`);
    process.exit(1);
  }
  return JSON.parse(fs.readFileSync(f, 'utf8'));
}

const args = process.argv.slice(2);
if (!args[0]) {
  console.error('Usage: node weekly-push.js <userId>');
  process.exit(1);
}

const userId = sanitizeId(args[0]);
const user   = loadUser(userId);

const city  = user.city  || '上海';
const units = user.units || 'metric';
const unit  = units === 'metric' ? 'C' : 'F';
const lang  = user.language || ((/[\u4e00-\u9fa5]/.test(city)) ? 'zh' : 'en');

const now = new Date();
// Next Monday
const dayOfWeek = now.getDay();
const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
const nextMonday = new Date(now);
nextMonday.setDate(now.getDate() + daysUntilMonday);
const nextSunday = new Date(nextMonday);
nextSunday.setDate(nextMonday.getDate() + 6);

const year = now.getFullYear();

if (lang === 'zh') {
  const MONTHS = ['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'];
  const fmtZh = d => `${d.getMonth()+1}月${d.getDate()}日`;
  const weekRange = `${fmtZh(nextMonday)}～${fmtZh(nextSunday)}`;

  console.log(`请为用户查询 ${city} 下周（${weekRange}）的天气预报，并按以下格式推送周报。

搜索步骤：
1. 搜索「${city} 下周天气预报」
2. 搜索「${city} ${year}年${nextMonday.getMonth()+1}月天气」

输出格式：
📅 ${city} 下周天气周报 · ${weekRange}
━━━━━━━━━━━━━━━━━━━━━━━

[每天一行，周一到周日]
{星期}（{日期}）  {天气图标} {天气}  🌡️{低温}~{高温}°${unit}  {简短提示}

━━━━━━━━━━━━━━━━━━━━━━━
📌 下周天气趋势：[整体天气变化，2-3句]
⚠️ 重要预警：[下周内极端天气，无则省略]
☂️ 本周备雨建议：[哪几天需要带伞]
👔 穿衣趋势：[温差变化与穿衣建议]
📅 最佳出行日：[下周最适合户外活动的1-2天]

💡 回复"今天天气"获取今日实况`);
} else {
  const WEEKDAYS_EN = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
  const MONTHS_EN   = ['January','February','March','April','May','June','July','August','September','October','November','December'];
  const fmtEn = d => `${MONTHS_EN[d.getMonth()]} ${d.getDate()}`;
  const weekRange = `${fmtEn(nextMonday)} – ${fmtEn(nextSunday)}`;

  console.log(`Please search for next week's weather forecast for ${city} (${weekRange}) and send the weekly report in the following format.

Search steps:
1. Search "${city} next week weather forecast"
2. Search "${city} ${MONTHS_EN[nextMonday.getMonth()]} ${year} weather"

Output format:
📅 ${city} Next Week Weather · ${weekRange}
━━━━━━━━━━━━━━━━━━━━━━━

[One line per day, Monday to Sunday]
{Day} ({Date})  {icon} {conditions}  🌡️{low}~{high}°${unit}  {brief tip}

━━━━━━━━━━━━━━━━━━━━━━━
📌 Weekly trend: [2–3 sentences on overall pattern]
⚠️ Alerts: [any extreme weather warnings; omit if none]
☂️ Umbrella days: [which days to carry an umbrella]
👔 What to wear: [temperature swings and outfit advice]
📅 Best day out: [1–2 best days for outdoor activities]

💡 Reply "today's weather" for today's detailed report`);
}


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