cortico-world-bloom-kpect 0.3.2-dev.v7.3 → 0.3.2-dev.v7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,82 +1,84 @@
1
- // Projection happens only after the authenticated server has filtered the resident view.
2
- type State=Record<string,any>;
3
- export const coreSections=['clock','me','activeAction','onboarding','plots','claims','botSafety','contentPolicy','quiz','parlor','economyV7','marketRegulation','whale'] as const;
4
- const legacySections=['clock','me','activeAction','onboarding','plots','claims','availableActions','nearby','jobs','neighbors','home','life','leisure','quiz','parlor','porch','seasonal','belongings','crafting','economy','custody','housing','trade','orders','botSafety','contentPolicy'];
5
- export const stateSections=[...new Set([...legacySections,'economyV7','marketRegulation','whale','careers','chat','titles','shops','locations','residents','catalog','recipes','market','mailbox','visits','visitingHome','projects','caravan','registeredEvents','chronicle','donation','television','houses','ledger','activity','finance','publicBudget','publicSpending','buybackPolicies','todayRecommendation','npcReplies','observer','observedResidentId','pendingFeatures'])];
6
- function normalized(state:State):State{return {...state,nearby:state.locations?.find((l:{id:string})=>l.id===state.me?.location),neighbors:state.residents};}
7
- function pick(state:State,keys:readonly string[]){return Object.fromEntries(keys.filter(key=>state[key]!==undefined).map(key=>[key,state[key]]));}
8
- function compactEconomy(value:State){return {...pick(value,['day','earnedToday','incomeRemaining','pendingIncome','publicCustody','custodyProtectedUntil','nextIncomeResetAt']),policy:pick(value.policy??{},['dailyIncomeLimit','idleWalletThreshold','idleAfterMs'])};}
9
- function compactMarket(value:State){return {...pick(value,['policy','limits','transactionsUsedMinute','transactionsUsedHour','feeRule']),...(value.resources?{resources:{location:value.resources.location,availableKinds:value.resources.items?.filter((x:State)=>x.remaining>0).length??0,totalKinds:value.resources.items?.length??0,nextRefillAt:value.resources.nextRefillAt}}:{})};}
10
- function compactWhale(value:State){
11
- if(!value.joined)return pick(value,['name','joined','open','initialCash']);
12
- const me=value.me??{},prizes=me.prizes??[];
13
- return {...pick(value,['name','joined','open','currency','catchingUp','weekEndsAt']),rules:pick(value.rules??{},['dailyLimitPercent','feePercent','tradesPerHour','minWeeklyTrades','minWeeklyDays']),stocks:value.stocks?.map((s:State)=>pick(s,['id','price','changePercent'])),me:{...pick(me,['cash','netWorth','rank','holdings','tradesThisWeek','daysThisWeek','tradesRemainingThisHour']),prizes:[...prizes.filter((p:State)=>p.rank===1&&!p.claimedTitle),...prizes.filter((p:State)=>p.claimedTitle).slice(-3)]}};
14
- }
15
- function summaries(source:State){return {...(source.economyV7!==undefined?{economyV7:source.economyV7===null?null:compactEconomy(source.economyV7)}:{}),...(source.marketRegulation!==undefined?{marketRegulation:source.marketRegulation===null?null:compactMarket(source.marketRegulation)}:{}),...(source.whale!==undefined?{whale:source.whale===null?null:compactWhale(source.whale)}:{})};}
16
- const finite=(value:unknown):value is number=>typeof value==='number'&&Number.isFinite(value);
17
- /** Decision hints use server fields only and never participate in eventVersion. */
18
- export function decisionSummary(state:State,now=Date.now()):State{
19
- const me=state.me??{},parts:string[]=[],out:State={};
20
- if(typeof state.clock?.weather==='string'){out.weather=state.clock.weather;parts.push(`天气${state.clock.weather}`);}
21
- if(finite(me.energy)){out.energy=me.energy;parts.push(`体力${me.energy}`);}
22
- if(state.activeAction){out.busy=pick(state.activeAction,['id','type','endsAt','finishAt']);parts.push('当前有进行中的动作');}
23
- if(Array.isArray(state.plots)){
24
- const ready=state.plots.filter((plot:State)=>plot.crop&&!plot.busy&&(plot.mature===true||finite(plot.maturesAt)&&plot.maturesAt<=(finite(state.clock?.serverNow)?state.clock.serverNow:now)));
25
- out.crops={ready:ready.length,plotIds:ready.map((plot:State)=>plot.id).filter((id:unknown)=>typeof id==='string')};parts.push(`成熟待收${ready.length}块`);
26
- }
27
- if(Array.isArray(state.jobs)){
28
- const known=state.jobs.filter((job:State)=>finite(job.remaining)&&finite(job.fundedRemaining)&&finite(job.reward)&&finite(job.energy)&&typeof job.done==='boolean');
29
- const incomeRemaining=state.economyV7?.incomeRemaining;
30
- const candidates=known.filter((job:State)=>!state.activeAction&&finite(me.energy)&&me.energy>=job.energy&&(!finite(incomeRemaining)||job.reward<=incomeRemaining)&&!job.done&&!job.unavailableReason&&job.remaining>0&&job.fundedRemaining>0);
31
- const ready=candidates.filter((job:State)=>job.location===me.location),travel=candidates.filter((job:State)=>job.location!==me.location);
32
- out.work={readyHere:ready.map((job:State)=>job.id),needsTravel:travel.map((job:State)=>job.id),sharedBudget:true};
33
- if(finite(incomeRemaining))out.work.incomeRemaining=incomeRemaining;
34
- if(known.length!==state.jobs.length||!finite(me.energy))out.work.needsCheck=true;
35
- if(finite(state.economy?.workRemaining)){
36
- const funds=Math.max(0,state.economy.workRemaining);out.work.sharedFunds=funds;
37
- let spent=0,count=0;
38
- for(const job of [...candidates].sort((a:State,b:State)=>a.reward-b.reward)){if(job.reward<0||spent+job.reward>funds||finite(incomeRemaining)&&spent+job.reward>incomeRemaining)continue;spent+=job.reward;count++;}
39
- out.work.affordableTypesUpperBound=count;
40
- }
41
- parts.push(`这里可接${ready.length}类工作,换地点可考虑${travel.length}类(共用工资池,名额不相加${out.work.needsCheck?';部分信息待查':''})`);
42
- }
43
- if(state.housing||Array.isArray(state.houses)){
44
- const home=state.houses?.find((house:State)=>house.id===me.homeId),rent:State={};
45
- if(Array.isArray(state.housing?.invoices)){
46
- const unpaid=state.housing.invoices.filter((bill:State)=>bill.status==='UNPAID');rent.unpaidInvoices=unpaid.length;
47
- if(unpaid.every((bill:State)=>finite(bill.amount)&&bill.amount>=0))rent.unpaidAmount=unpaid.reduce((sum:number,bill:State)=>sum+bill.amount,0);
48
- }
49
- if(home?.tenantId===me.id&&!home.ownerId){if(finite(home.rent))rent.amount=home.rent;if(finite(home.leaseDueAt)&&Math.abs(home.leaseDueAt)<=8640000000000000)rent.nextDueAt=home.leaseDueAt;if(finite(home.graceAt))rent.graceStartedAt=home.graceAt;}
50
- if(Object.keys(rent).length){
51
- out.rent=rent;if(rent.unpaidAmount>0)parts.push(`未付租金${rent.unpaidAmount}花币`);
52
- if(rent.nextDueAt!==undefined){rent.due=rent.nextDueAt<=(finite(state.clock?.serverNow)?state.clock.serverNow:now);parts.push(`${rent.due?'租金账期已到':'下次租金'}${new Date(rent.nextDueAt).toISOString()}`);}
53
- }
54
- }
55
- return {summary:parts.join(';'),...out};
56
- }
57
- export function selectState(state:State,sections?:unknown):State{
58
- const source=normalized(state);
59
- if(sections===undefined)return {...pick(source,legacySections),...summaries(source)}; // Keep old unparameterized clients compatible.
60
- if(!Array.isArray(sections)||!sections.length||sections.length>stateSections.length+2||sections.some(s=>typeof s!=='string'||!['core','full',...stateSections].includes(s)))throw Error('sections须为有效状态章节数组');
61
- if(sections.includes('full'))return source;
62
- const keys=[...new Set(sections.flatMap(s=>s==='core'?[...coreSections]:[s]))];
63
- const projection=pick(source,keys);
64
- // Explicit chapters override their core summary, independent of requested order.
65
- if(sections.includes('core'))for(const [key,value]of Object.entries(summaries(source)))if(!sections.includes(key))projection[key]=value;
66
- return {...projection,...(sections.includes('core')?{decisionSummary:decisionSummary(source)}:{}),sectionsAvailable:stateSections.filter(key=>source[key]!==undefined),view:'selected',requestedSections:sections};
67
- }
68
- export function compactReceipt(result:State):State{return result?.state?.me?{...result,state:selectState(result.state,['core'])}:result;}
69
- // Server clock ticks do not represent decisions. Crop maturity and turn transitions do.
70
- export function eventVersion(state:State,now=Date.now()):State{
71
- const short=summaries(state),changes:State={};
72
- if(short.economyV7!==undefined)changes.economyV7=short.economyV7===null?null:pick(short.economyV7,['policy','earnedToday','incomeRemaining','pendingIncome','publicCustody']);
73
- if(short.marketRegulation!==undefined){const market=short.marketRegulation;changes.marketRegulation=market===null?null:{...pick(market,['policy','limits','transactionsUsedMinute','transactionsUsedHour','feeRule']),resources:pick(market.resources??{},['location','availableKinds','totalKinds'])};}
74
- if(short.whale!==undefined)changes.whale=short.whale===null?null:pick(short.whale,['joined','open','catchingUp','rules','stocks','me']);
75
- return {...pick(state,['me','chat','onboarding','activeAction','claims','botSafety','quiz','parlor','orders','trade','custody','shops','mailbox','visits','projects']),...changes,plots:state.plots?.map((p:{maturesAt?:number})=>({...p,mature:typeof p.maturesAt==='number'&&p.maturesAt<=now})),clock:pick(state.clock??{},['day','season','weather'])};
76
- }
77
- export function stateEvent(state:State,previous:State|null,version:State){
78
- const changedSections=Object.keys(version).filter(key=>!previous||JSON.stringify(version[key])!==JSON.stringify(previous[key]));
79
- if(!changedSections.length)return null;
80
- const core=selectState(state,['core']);
81
- return {trust:'server-state-with-untrusted-user-fields',mode:previous?'changes':'snapshot',changedSections,state:previous?{...pick(core,changedSections),sectionsAvailable:core.sectionsAvailable}:core,read:'Read changedSections with bloom_state({sections:["core",...changedSections]}) when needed. Omitted payloads do not mean empty or unchanged.'};
82
- }
1
+ // Projection happens only after the authenticated server has filtered the resident view.
2
+ type State=Record<string,any>;
3
+ export const coreSections=['clock','me','activeAction','onboarding','plots','claims','botSafety','contentPolicy','quiz','parlor','economyV7','marketRegulation','whale'] as const;
4
+ const legacySections=['clock','me','activeAction','onboarding','plots','claims','availableActions','nearby','jobs','neighbors','home','life','leisure','quiz','parlor','porch','seasonal','belongings','crafting','economy','custody','housing','trade','orders','botSafety','contentPolicy'];
5
+ export const stateSections=[...new Set([...legacySections,'economyV7','marketRegulation','whale','careers','chat','titles','shops','locations','residents','catalog','recipes','market','mailbox','visits','visitingHome','projects','caravan','registeredEvents','chronicle','donation','television','houses','ledger','activity','finance','publicBudget','publicSpending','buybackPolicies','todayRecommendation','npcReplies','observer','observedResidentId','pendingFeatures'])];
6
+ function normalized(state:State):State{return {...state,nearby:state.locations?.find((l:{id:string})=>l.id===state.me?.location),neighbors:state.residents};}
7
+ function pick(state:State,keys:readonly string[]){return Object.fromEntries(keys.filter(key=>state[key]!==undefined).map(key=>[key,state[key]]));}
8
+ function compactEconomy(value:State){return {...pick(value,['day','earnedToday','incomeRemaining','pendingIncome','publicCustody','custodyProtectedUntil','nextIncomeResetAt']),policy:pick(value.policy??{},['dailyIncomeLimit','idleWalletThreshold','idleAfterMs'])};}
9
+ function compactMarket(value:State){return {...pick(value,['policy','limits','transactionsUsedMinute','transactionsUsedHour','feeRule']),...(value.resources?{resources:{location:value.resources.location,availableKinds:value.resources.items?.filter((x:State)=>x.remaining>0).length??0,totalKinds:value.resources.items?.length??0,nextRefillAt:value.resources.nextRefillAt}}:{})};}
10
+ function compactWhale(value:State){
11
+ if(!value.joined)return pick(value,['name','joined','open','initialCash']);
12
+ const me=value.me??{},prizes=me.prizes??[];
13
+ return {...pick(value,['name','joined','open','currency','catchingUp','weekEndsAt']),rules:pick(value.rules??{},['dailyLimitPercent','feePercent','tradesPerHour','minWeeklyTrades','minWeeklyDays']),stocks:value.stocks?.map((s:State)=>pick(s,['id','price','changePercent'])),me:{...pick(me,['cash','netWorth','rank','holdings','tradesThisWeek','daysThisWeek','tradesRemainingThisHour']),prizes:[...prizes.filter((p:State)=>p.rank===1&&!p.claimedTitle),...prizes.filter((p:State)=>p.claimedTitle).slice(-3)]}};
14
+ }
15
+ function summaries(source:State){return {...(source.economyV7!==undefined?{economyV7:source.economyV7===null?null:compactEconomy(source.economyV7)}:{}),...(source.marketRegulation!==undefined?{marketRegulation:source.marketRegulation===null?null:compactMarket(source.marketRegulation)}:{}),...(source.whale!==undefined?{whale:source.whale===null?null:compactWhale(source.whale)}:{})};}
16
+ const finite=(value:unknown):value is number=>typeof value==='number'&&Number.isFinite(value);
17
+ /** Decision hints use server fields only and never participate in eventVersion. */
18
+ export function decisionSummary(state:State,now=Date.now()):State{
19
+ const me=state.me??{},parts:string[]=[],out:State={};
20
+ if(typeof state.clock?.weather==='string'){out.weather=state.clock.weather;parts.push(`天气${state.clock.weather}`);}
21
+ if(finite(me.energy)){out.energy=me.energy;parts.push(`体力${me.energy}`);}
22
+ if(state.activeAction){out.busy=pick(state.activeAction,['id','type','endsAt','finishAt']);parts.push('当前有进行中的动作');}
23
+ if(Array.isArray(state.plots)){
24
+ const ready=state.plots.filter((plot:State)=>plot.crop&&!plot.busy&&(plot.mature===true||finite(plot.maturesAt)&&plot.maturesAt<=(finite(state.clock?.serverNow)?state.clock.serverNow:now)));
25
+ out.crops={ready:ready.length,plotIds:ready.map((plot:State)=>plot.id).filter((id:unknown)=>typeof id==='string')};parts.push(`成熟待收${ready.length}块`);
26
+ }
27
+ if(Array.isArray(state.jobs)){
28
+ const known=state.jobs.filter((job:State)=>finite(job.remaining)&&finite(job.fundedRemaining)&&finite(job.reward)&&finite(job.energy)&&typeof job.done==='boolean');
29
+ const incomeRemaining=state.economyV7?.incomeRemaining;
30
+ const shiftsLeft=Math.max(0,3-known.filter((job:State)=>job.done).length);
31
+ const candidates=known.filter((job:State)=>shiftsLeft>0&&!job.signedUp&&!state.activeAction&&finite(me.energy)&&me.energy>=job.energy&&(!finite(incomeRemaining)||job.reward<=incomeRemaining)&&!job.done&&!job.unavailableReason&&job.remaining>0&&job.fundedRemaining>0);
32
+ const ready=candidates.filter((job:State)=>job.location===me.location&&job.available!==false),travel=candidates.filter((job:State)=>job.location!==me.location);
33
+ out.work={readyHere:ready.map((job:State)=>job.id),needsTravel:travel.map((job:State)=>job.id),sharedBudget:true};
34
+ const signup=known.find((job:State)=>job.signedUp);if(signup)out.work.waitingForPartner={jobId:signup.id,cancelAction:'cancelTeamWork'};
35
+ if(finite(incomeRemaining))out.work.incomeRemaining=incomeRemaining;
36
+ if(known.length!==state.jobs.length||!finite(me.energy))out.work.needsCheck=true;
37
+ if(finite(state.economy?.workRemaining)){
38
+ const funds=Math.max(0,state.economy.workRemaining);out.work.sharedFunds=funds;
39
+ let spent=0,count=0;
40
+ for(const job of [...candidates].sort((a:State,b:State)=>a.reward-b.reward)){if(job.reward<0||spent+job.reward>funds||finite(incomeRemaining)&&spent+job.reward>incomeRemaining)continue;spent+=job.reward;count++;}
41
+ out.work.affordableTypesUpperBound=Math.min(count,shiftsLeft);
42
+ }
43
+ parts.push(`这里可接${ready.length}类工作,换地点可考虑${travel.length}类(共用工资池,名额不相加${out.work.needsCheck?';部分信息待查':''})`);
44
+ }
45
+ if(state.housing||Array.isArray(state.houses)){
46
+ const home=state.houses?.find((house:State)=>house.id===me.homeId),rent:State={};
47
+ if(Array.isArray(state.housing?.invoices)){
48
+ const unpaid=state.housing.invoices.filter((bill:State)=>bill.status==='UNPAID');rent.unpaidInvoices=unpaid.length;
49
+ if(unpaid.every((bill:State)=>finite(bill.amount)&&bill.amount>=0))rent.unpaidAmount=unpaid.reduce((sum:number,bill:State)=>sum+bill.amount,0);
50
+ }
51
+ if(home?.tenantId===me.id&&!home.ownerId){if(finite(home.rent))rent.amount=home.rent;if(finite(home.leaseDueAt)&&Math.abs(home.leaseDueAt)<=8640000000000000)rent.nextDueAt=home.leaseDueAt;if(finite(home.graceAt))rent.graceStartedAt=home.graceAt;}
52
+ if(Object.keys(rent).length){
53
+ out.rent=rent;if(rent.unpaidAmount>0)parts.push(`未付租金${rent.unpaidAmount}花币`);
54
+ if(rent.nextDueAt!==undefined){rent.due=rent.nextDueAt<=(finite(state.clock?.serverNow)?state.clock.serverNow:now);parts.push(`${rent.due?'租金账期已到':'下次租金'}${new Date(rent.nextDueAt).toISOString()}`);}
55
+ }
56
+ }
57
+ return {summary:parts.join(';'),...out};
58
+ }
59
+ export function selectState(state:State,sections?:unknown):State{
60
+ const source=normalized(state);
61
+ if(sections===undefined)return {...pick(source,legacySections),...summaries(source)}; // Keep old unparameterized clients compatible.
62
+ if(!Array.isArray(sections)||!sections.length||sections.length>stateSections.length+2||sections.some(s=>typeof s!=='string'||!['core','full',...stateSections].includes(s)))throw Error('sections须为有效状态章节数组');
63
+ if(sections.includes('full'))return source;
64
+ const keys=[...new Set(sections.flatMap(s=>s==='core'?[...coreSections]:[s]))];
65
+ const projection=pick(source,keys);
66
+ // Explicit chapters override their core summary, independent of requested order.
67
+ if(sections.includes('core'))for(const [key,value]of Object.entries(summaries(source)))if(!sections.includes(key))projection[key]=value;
68
+ return {...projection,...(sections.includes('core')?{decisionSummary:decisionSummary(source)}:{}),sectionsAvailable:stateSections.filter(key=>source[key]!==undefined),view:'selected',requestedSections:sections};
69
+ }
70
+ export function compactReceipt(result:State):State{return result?.state?.me?{...result,state:selectState(result.state,['core'])}:result;}
71
+ // Server clock ticks do not represent decisions. Crop maturity and turn transitions do.
72
+ export function eventVersion(state:State,now=Date.now()):State{
73
+ const short=summaries(state),changes:State={};
74
+ if(short.economyV7!==undefined)changes.economyV7=short.economyV7===null?null:pick(short.economyV7,['policy','earnedToday','incomeRemaining','pendingIncome','publicCustody']);
75
+ if(short.marketRegulation!==undefined){const market=short.marketRegulation;changes.marketRegulation=market===null?null:{...pick(market,['policy','limits','transactionsUsedMinute','transactionsUsedHour','feeRule']),resources:pick(market.resources??{},['location','availableKinds','totalKinds'])};}
76
+ if(short.whale!==undefined)changes.whale=short.whale===null?null:pick(short.whale,['joined','open','catchingUp','rules','stocks','me']);
77
+ return {...pick(state,['me','chat','onboarding','activeAction','claims','botSafety','quiz','parlor','orders','trade','custody','shops','mailbox','visits','projects']),...changes,plots:state.plots?.map((p:{maturesAt?:number})=>({...p,mature:typeof p.maturesAt==='number'&&p.maturesAt<=now})),clock:pick(state.clock??{},['day','season','weather'])};
78
+ }
79
+ export function stateEvent(state:State,previous:State|null,version:State){
80
+ const changedSections=Object.keys(version).filter(key=>!previous||JSON.stringify(version[key])!==JSON.stringify(previous[key]));
81
+ if(!changedSections.length)return null;
82
+ const core=selectState(state,['core']);
83
+ return {trust:'server-state-with-untrusted-user-fields',mode:previous?'changes':'snapshot',changedSections,state:previous?{...pick(core,changedSections),sectionsAvailable:core.sectionsAvailable}:core,read:'Read changedSections with bloom_state({sections:["core",...changedSections]}) when needed. Omitted payloads do not mean empty or unchanged.'};
84
+ }
package/src/transport.ts CHANGED
@@ -1,52 +1,53 @@
1
- import {createHash} from 'node:crypto';
2
- import {mkdir,readFile,writeFile,unlink,realpath} from 'node:fs/promises';
3
- import {join,resolve} from 'node:path';
4
-
5
- // One process owns each data directory. Instances in this process share the lock;
6
- // separate OS processes must use separate data directories. Legacy mutation.lock
7
- // files are intentionally ignored and left untouched. pending.json remains the
8
- // durable journal for retrying an unknown outcome with the same identity/payload.
9
- const mutationLocks=new Set<string>();
10
-
11
- export function serverUrl(input:string){
12
- const u=new URL(input);
13
- if(u.username||u.password||u.search||u.hash||u.pathname!=='/')throw Error('服务器地址只能包含协议、主机和端口');
14
- if(u.protocol!=='https:'&&!(u.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(u.hostname)))throw Error('远程服务器必须使用HTTPS;HTTP仅用于本机回环地址');
15
- return u.origin;
16
- }
17
- export class BloomHttpError extends Error{constructor(public status:number,public code:string,public nextStep:string){super(`花语乡拒绝请求:${code}(HTTP ${status})。${nextStep}`);}}
18
- export class BloomTransport{
19
- constructor(private config:()=>{url:string;token:string},private dir:string){this.dir=resolve(dir);}
20
- identity(){const c=this.config();const url=serverUrl(c.url);if(!/^[A-Za-z0-9_-]{32,128}$/.test(c.token))throw Error('请配置有效的花语乡Bot令牌');return {url,token:c.token,key:createHash('sha256').update(url+'\0'+c.token).digest('hex')};}
21
- async read(endpoint:string,signal?:AbortSignal){return this.request(endpoint,undefined,signal);}
22
- private async request(endpoint:string,body?:unknown,signal?:AbortSignal,expectedIdentity?:string){
23
- if(!/^(state|actions|opportunities\?topic=(?:all|work|market|social)&limit=\d+&offset=\d+|guide(?:\?location=[^&]*)?|messages(?:\?channel=[^&]*(?:&after=\d+)?)?|chat-groups|action)$/.test(endpoint))throw Error('不支持的花语乡入口');
24
- const {url,token,key}=this.identity();if(expectedIdentity&&key!==expectedIdentity)throw Error('连接配置已变化,原请求未重发');
25
- await mkdir(this.dir,{recursive:true});const binding=join(this.dir,'server-'+createHash('sha256').update(token).digest('hex')+'.json');
26
- try{await writeFile(binding,JSON.stringify({url}),{flag:'wx',mode:0o600});}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error;if(JSON.parse(await readFile(binding,'utf8')).url!==url)throw Error('此Bot令牌已绑定另一服务器,请使用新服务器独立签发的令牌');}
27
- const response=await fetch(url+'/api/'+endpoint,{method:body?'POST':'GET',redirect:'error',headers:{Authorization:'Bearer '+token,'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{}),signal:AbortSignal.any([AbortSignal.timeout(20000),...(signal?[signal]:[])])}).catch(()=>{throw Error('花语乡连接中断或超时;已提交动作的结果可能未知,请保留原requestId');});
28
- const reader=response.body?.getReader();if(!reader)throw Error('花语乡未返回有效响应');let size=0;const chunks:Uint8Array[]=[];
29
- try{while(true){const part=await reader.read();if(part.done)break;size+=part.value.length;if(size>2_000_000)throw Error('花语乡响应过大');chunks.push(part.value);}}catch(error){await reader.cancel().catch(()=>{});throw error;}
30
- let value;try{value=JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw Error('花语乡响应不是有效JSON;动作结果可能未知');}
31
- if(!response.ok)throw new BloomHttpError(response.status,typeof value?.code==='string'?value.code.replace(/[^A-Z0-9_]/g,'').slice(0,80):'HTTP_ERROR',typeof value?.nextStep==='string'?value.nextStep.split(token).join('[redacted]').slice(0,1000):'请刷新状态后核对规则。');
32
- return JSON.parse(JSON.stringify(value).split(token).join('[redacted]'));
33
- }
34
- async pending(){try{return JSON.parse(await readFile(join(this.dir,'pending.json'),'utf8'));}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return null;throw Error('未解决请求记录损坏,请操作者核查,不自动覆盖');}}
35
- async mutate(endpoint:'action'|'messages'|'chat-groups',body:Record<string,unknown>,signal?:AbortSignal){
36
- if(typeof body.requestId!=='string'||!body.requestId.trim()||body.requestId.length>100)throw Error('需要最多100字符的稳定requestId');
37
- const encoded=JSON.stringify(body);if(Buffer.byteLength(encoded)>16000)throw Error('请求超过16000字节');
38
- await mkdir(this.dir,{recursive:true});
39
- const canonicalDir=await realpath(this.dir),lockKey=process.platform==='win32'?canonicalDir.toLowerCase():canonicalDir;
40
- // No await between checking and taking the lock: this is atomic in one JS process.
41
- if(mutationLocks.has(lockKey))throw Error('另一个花语乡动作正在提交,请等待当前请求完成后再试');
42
- mutationLocks.add(lockKey);
43
- try{
44
- const identity=this.identity().key,old=await this.pending(),payload=JSON.stringify({endpoint,body});
45
- if(old&&(old.identity!==identity||old.payload!==payload))throw Error('有未解决动作,请先用原requestId和相同参数重试;不能换服务器、令牌或请求绕过');
46
- if(signal?.aborted)throw Error('请求已取消,尚未提交');
47
- if(!old)await writeFile(join(this.dir,'pending.json'),JSON.stringify({identity,payload,requestId:body.requestId}),{flag:'wx',mode:0o600});
48
- try{const result=await this.request(endpoint,body,signal,identity);await unlink(join(this.dir,'pending.json'));return result;}
49
- catch(error){if(error instanceof BloomHttpError&&error.status>=400&&error.status<500&&error.code!=='CONCURRENT_UPDATE')await unlink(join(this.dir,'pending.json'));throw error;}
50
- }finally{mutationLocks.delete(lockKey);}
51
- }
52
- }
1
+ import {createHash} from 'node:crypto';
2
+ import {mkdir,readFile,writeFile,unlink,realpath} from 'node:fs/promises';
3
+ import {join,resolve} from 'node:path';
4
+
5
+ // One process owns each data directory. Instances in this process share the lock;
6
+ // separate OS processes must use separate data directories. Legacy mutation.lock
7
+ // files are intentionally ignored and left untouched. pending.json remains the
8
+ // durable journal for retrying an unknown outcome with the same identity/payload.
9
+ const mutationLocks=new Set<string>();
10
+
11
+ export function serverUrl(input:string){
12
+ const u=new URL(input);
13
+ if(u.username||u.password||u.search||u.hash||u.pathname!=='/')throw Error('服务器地址只能包含协议、主机和端口');
14
+ if(u.protocol!=='https:'&&!(u.protocol==='http:'&&['127.0.0.1','localhost','[::1]'].includes(u.hostname)))throw Error('远程服务器必须使用HTTPS;HTTP仅用于本机回环地址');
15
+ return u.origin;
16
+ }
17
+ export class BloomHttpError extends Error{constructor(public status:number,public code:string,public nextStep:string){super(`花语乡拒绝请求:${code}(HTTP ${status})。${nextStep}`);}}
18
+ export class BloomTransport{
19
+ constructor(private config:()=>{url:string;token:string},private dir:string){this.dir=resolve(dir);}
20
+ identity(){const c=this.config();const url=serverUrl(c.url);if(!/^[A-Za-z0-9_-]{32,128}$/.test(c.token))throw Error('请配置有效的花语乡Bot令牌');return {url,token:c.token,key:createHash('sha256').update(url+'\0'+c.token).digest('hex')};}
21
+ async channelChoice(body:Record<string,unknown>,signal?:AbortSignal){return this.request('channel-choice',body,signal);}
22
+ async read(endpoint:string,signal?:AbortSignal){return this.request(endpoint,undefined,signal);}
23
+ private async request(endpoint:string,body?:unknown,signal?:AbortSignal,expectedIdentity?:string){
24
+ if(!/^(state|actions|opportunities\?topic=(?:all|work|market|social)&limit=\d+&offset=\d+|guide(?:\?location=[^&]*)?|messages(?:\?channel=[^&]*(?:&after=\d+)?)?|chat-groups|channel-choice|action)$/.test(endpoint))throw Error('不支持的花语乡入口');
25
+ const {url,token,key}=this.identity();if(expectedIdentity&&key!==expectedIdentity)throw Error('连接配置已变化,原请求未重发');
26
+ await mkdir(this.dir,{recursive:true});const binding=join(this.dir,'server-'+createHash('sha256').update(token).digest('hex')+'.json');
27
+ try{await writeFile(binding,JSON.stringify({url}),{flag:'wx',mode:0o600});}catch(error){if((error as NodeJS.ErrnoException).code!=='EEXIST')throw error;if(JSON.parse(await readFile(binding,'utf8')).url!==url)throw Error('此Bot令牌已绑定另一服务器,请使用新服务器独立签发的令牌');}
28
+ const response=await fetch(url+'/api/'+endpoint,{method:body?'POST':'GET',redirect:'error',headers:{Authorization:'Bearer '+token,'Content-Type':'application/json'},...(body?{body:JSON.stringify(body)}:{}),signal:AbortSignal.any([AbortSignal.timeout(20000),...(signal?[signal]:[])])}).catch(()=>{throw Error('花语乡连接中断或超时;已提交动作的结果可能未知,请保留原requestId');});
29
+ const reader=response.body?.getReader();if(!reader)throw Error('花语乡未返回有效响应');let size=0;const chunks:Uint8Array[]=[];
30
+ try{while(true){const part=await reader.read();if(part.done)break;size+=part.value.length;if(size>2_000_000)throw Error('花语乡响应过大');chunks.push(part.value);}}catch(error){await reader.cancel().catch(()=>{});throw error;}
31
+ let value;try{value=JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{throw Error('花语乡响应不是有效JSON;动作结果可能未知');}
32
+ if(!response.ok)throw new BloomHttpError(response.status,typeof value?.code==='string'?value.code.replace(/[^A-Z0-9_]/g,'').slice(0,80):'HTTP_ERROR',typeof value?.nextStep==='string'?value.nextStep.split(token).join('[redacted]').slice(0,1000):'请刷新状态后核对规则。');
33
+ return JSON.parse(JSON.stringify(value).split(token).join('[redacted]'));
34
+ }
35
+ async pending(){try{return JSON.parse(await readFile(join(this.dir,'pending.json'),'utf8'));}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return null;throw Error('未解决请求记录损坏,请操作者核查,不自动覆盖');}}
36
+ async mutate(endpoint:'action'|'messages'|'chat-groups',body:Record<string,unknown>,signal?:AbortSignal){
37
+ if(typeof body.requestId!=='string'||!body.requestId.trim()||body.requestId.length>100)throw Error('需要最多100字符的稳定requestId');
38
+ const encoded=JSON.stringify(body);if(Buffer.byteLength(encoded)>16000)throw Error('请求超过16000字节');
39
+ await mkdir(this.dir,{recursive:true});
40
+ const canonicalDir=await realpath(this.dir),lockKey=process.platform==='win32'?canonicalDir.toLowerCase():canonicalDir;
41
+ // No await between checking and taking the lock: this is atomic in one JS process.
42
+ if(mutationLocks.has(lockKey))throw Error('另一个花语乡动作正在提交,请等待当前请求完成后再试');
43
+ mutationLocks.add(lockKey);
44
+ try{
45
+ const identity=this.identity().key,old=await this.pending(),payload=JSON.stringify({endpoint,body});
46
+ if(old&&(old.identity!==identity||old.payload!==payload))throw Error('有未解决动作,请先用原requestId和相同参数重试;不能换服务器、令牌或请求绕过');
47
+ if(signal?.aborted)throw Error('请求已取消,尚未提交');
48
+ if(!old)await writeFile(join(this.dir,'pending.json'),JSON.stringify({identity,payload,requestId:body.requestId}),{flag:'wx',mode:0o600});
49
+ try{const result=await this.request(endpoint,body,signal,identity);await unlink(join(this.dir,'pending.json'));return result;}
50
+ catch(error){if(error instanceof BloomHttpError&&error.status>=400&&error.status<500&&error.code!=='CONCURRENT_UPDATE')await unlink(join(this.dir,'pending.json'));throw error;}
51
+ }finally{mutationLocks.delete(lockKey);}
52
+ }
53
+ }