feat: companion 作息、主動發話、適應度與外部素材

- 作息與疲勞、想念累積與時間感,主動發話沿用 care.ts 的冷卻、日限、靜音
- 想家與適應度(9.13)
- 獨立腳本 fetch-materials.mjs 由系統排程寫入快取,hook 只讀、不連網
- 新增 /jsc-mod-companion:materials 與 setup location=、feeds=
- 天氣微調心情基準,新聞依適應度每天最多 2 則並寫入情節記憶
- 版本 0.0.5

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 09:29:46 +00:00
co-authored by Claude Sonnet 5.5
parent eeed12741e
commit 7e6e323473
15 changed files with 1237 additions and 20 deletions
@@ -0,0 +1,108 @@
// node --test scripts/fetch-materials.test.mjs
import assert from 'node:assert/strict'
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { promisify } from 'node:util'
import { fileURLToPath } from 'node:url'
import { parseFeed, run, weatherTitle } from './fetch-materials.mjs'
const RSS = `<?xml version="1.0"?><rss><channel>
<item><title><![CDATA[第一則 &amp; 標題]]></title><link>https://n.example/1</link><description>&lt;p&gt;摘要&lt;/p&gt;內文</description><pubDate>x</pubDate></item>
<item><title>沒有連結</title></item>
<item><title>第二則</title><link>https://n.example/2</link><description>d2</description></item>
</channel></rss>`
const ATOM = `<feed><entry><title>原子</title><link href="https://a.example/1"/><summary>s</summary></entry></feed>`
const mkFetch = routes => async url => {
for (const [k, v] of Object.entries(routes)) {
if (url.includes(k)) {
if (v instanceof Error) throw v
return { ok: true, status: 200, json: async () => v, text: async () => v }
}
}
return { ok: false, status: 404, json: async () => ({}), text: async () => '' }
}
const WEATHER = { current: { temperature_2m: 26.4, weather_code: 61 } }
const NOW = Date.UTC(2026, 9, 14, 8, 0)
async function withRoot(user, fn) {
const root = await mkdtemp(join(tmpdir(), 'companion-'))
try {
if (user) await writeFile(join(root, 'user.json'), JSON.stringify(user))
return await fn(root)
} finally {
await rm(root, { recursive: true, force: true })
}
}
test('RSS 與 Atom 只留標題、摘要、連結、時間,沒有連結的略過', () => {
const items = parseFeed(RSS, NOW)
assert.deepEqual(items.map(i => i.title), ['第一則 & 標題', '第二則'])
assert.equal(items[0].summary, '摘要 內文')
assert.deepEqual(Object.keys(items[0]).sort(), ['at', 'link', 'summary', 'title'])
assert.equal(parseFeed(ATOM, NOW)[0].link, 'https://a.example/1')
})
test('天氣代碼對應標題', () => {
assert.equal(weatherTitle(0), '晴')
assert.equal(weatherTitle(63), '雨')
assert.equal(weatherTitle(95), '雷雨')
})
test('沒有 location 就不啟用,也不寫快取、不連網', async () => {
await withRoot({ feeds: ['https://n.example/rss'] }, async root => {
let called = 0
const r = await run({ root, now: NOW, fetch: async () => void called++ })
assert.equal(r.enabled, false)
assert.equal(called, 0)
await assert.rejects(readFile(join(root, 'materials.json')))
})
})
test('有 location:寫入天氣與新聞快取', async () => {
await withRoot({ location: '25.03,121.56', feeds: ['https://n.example/rss'] }, async root => {
const fetch = mkFetch({ 'api.open-meteo.com': WEATHER, 'n.example/rss': RSS })
const r = await run({ root, now: NOW, fetch })
assert.deepEqual([r.enabled, r.weather, r.news], [true, true, 2])
const cache = JSON.parse(await readFile(join(root, 'materials.json'), 'utf8'))
assert.equal(cache.weather.title, '雨')
assert.equal(cache.weather.at, NOW)
assert.equal(cache.news.length, 2)
})
})
test('地名會先做地理編碼', async () => {
await withRoot({ location: '台北' }, async root => {
const fetch = mkFetch({ 'geocoding-api': { results: [{ latitude: 25.04, longitude: 121.5, name: '台北' }] }, 'api.open-meteo.com': WEATHER })
const r = await run({ root, now: NOW, fetch })
assert.equal(r.weather, true)
})
})
test('抓取失敗時保留原快取,不覆蓋成空的', async () => {
await withRoot({ location: '25,121', feeds: ['https://n.example/rss'] }, async root => {
const old = { weather: { title: '晴', summary: 's', link: 'https://x', at: 1 }, news: [{ title: '舊聞', summary: '', link: 'https://o', at: 1 }] }
await writeFile(join(root, 'materials.json'), JSON.stringify(old))
const fetch = mkFetch({ 'api.open-meteo.com': new Error('offline'), 'n.example/rss': new Error('offline') })
const r = await run({ root, now: NOW, fetch })
assert.equal(r.errors.length, 2)
assert.deepEqual(JSON.parse(await readFile(join(root, 'materials.json'), 'utf8')), old)
})
})
test('RSS 預設空清單時只更新天氣', async () => {
await withRoot({ location: '25,121' }, async root => {
const r = await run({ root, now: NOW, fetch: mkFetch({ 'api.open-meteo.com': WEATHER }) })
assert.equal(r.news, 0)
})
})
test('腳本可單獨執行(未啟用時正常結束)', async () => {
await withRoot(null, async root => {
const { stdout } = await promisify(execFile)(process.execPath, [fileURLToPath(new URL('./fetch-materials.mjs', import.meta.url))], { env: { ...process.env, COMPANION_ROOT: root } })
assert.match(stdout, /未啟用/)
})
})