174 lines
7.6 KiB
TypeScript
174 lines
7.6 KiB
TypeScript
export type ConversationChannel='say'|'whisper';
|
|
|
|
export type ConversationRequest={
|
|
socialTurn?:0|1|2|3;
|
|
id:string;
|
|
createdAt:string;
|
|
speakerName:string;
|
|
message:string;
|
|
channel:ConversationChannel;
|
|
zoneName:string;
|
|
receiver:string;
|
|
selectedPlayer:string;
|
|
nearbyBots:string[];
|
|
status:'pending'|'processing'|'awaiting_delivery'|'completed'|'failed';
|
|
attempts:number;
|
|
};
|
|
|
|
async function ensure(db:D1Database){
|
|
await db.prepare(`CREATE TABLE IF NOT EXISTS conversation_requests (
|
|
id TEXT PRIMARY KEY NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
speaker_name TEXT NOT NULL,
|
|
message TEXT NOT NULL,
|
|
channel TEXT NOT NULL,
|
|
zone_name TEXT NOT NULL,
|
|
receiver TEXT NOT NULL,
|
|
selected_player TEXT NOT NULL,
|
|
nearby_bots TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
attempts INTEGER NOT NULL DEFAULT 0,
|
|
started_at TEXT,
|
|
completed_at TEXT,
|
|
result TEXT,
|
|
error TEXT
|
|
)`).run();
|
|
await db.prepare(`CREATE INDEX IF NOT EXISTS idx_conversation_requests_status_created
|
|
ON conversation_requests(status,created_at)`).run();
|
|
}
|
|
|
|
function text(payload:Record<string,unknown>,key:string,max:number){
|
|
const value=payload[key];
|
|
return typeof value==='string'?value.trim().slice(0,max):'';
|
|
}
|
|
|
|
export async function queueConversationEvent(db:D1Database,kind:string,payload:Record<string,unknown>){
|
|
if(payload.isBot===true)return null;
|
|
if(kind!=='player.chat'&&kind!=='player.whisper')return null;
|
|
|
|
const speakerName=text(payload,'name',40);
|
|
const message=text(payload,'message',500);
|
|
if(!speakerName||!message)return null;
|
|
if(message.startsWith('.')||message.startsWith('!'))return null;
|
|
|
|
if(kind==='player.whisper'&&payload.receiverIsBot!==true)return null;
|
|
if(kind==='player.chat'&&!text(payload,'selectedPlayer',40)&&!text(payload,'nearbyBots',1000))return null;
|
|
|
|
await ensure(db);
|
|
const id=crypto.randomUUID();
|
|
const createdAt=new Date().toISOString();
|
|
const channel:ConversationChannel=kind==='player.whisper'?'whisper':'say';
|
|
await db.prepare(`INSERT INTO conversation_requests
|
|
(id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts)
|
|
VALUES (?,?,?,?,?,?,?,?,?,'pending',0)`)
|
|
.bind(
|
|
id,createdAt,speakerName,message,channel,
|
|
text(payload,'zoneName',120),
|
|
text(payload,'receiver',40),
|
|
text(payload,'selectedPlayer',40),
|
|
text(payload,'nearbyBots',1000)
|
|
).run();
|
|
return {id,createdAt};
|
|
}
|
|
|
|
export async function leaseConversation(db:D1Database):Promise<ConversationRequest|null>{
|
|
await ensure(db);
|
|
const staleBefore=new Date(Date.now()-90_000).toISOString();
|
|
await db.prepare(`UPDATE conversation_requests
|
|
SET status='pending',started_at=NULL
|
|
WHERE status='processing' AND started_at IS NOT NULL AND started_at<?`)
|
|
.bind(staleBefore).run();
|
|
|
|
const row=await db.prepare(`SELECT id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts
|
|
FROM conversation_requests WHERE status='pending' ORDER BY CASE WHEN channel LIKE 'social-%' THEN 1 ELSE 0 END,created_at LIMIT 1`).first<any>();
|
|
if(!row)return null;
|
|
|
|
const startedAt=new Date().toISOString();
|
|
const updated=await db.prepare(`UPDATE conversation_requests
|
|
SET status='processing',started_at=?,attempts=attempts+1
|
|
WHERE id=? AND status='pending'`).bind(startedAt,row.id).run();
|
|
if(updated.meta.changes!==1)return null;
|
|
|
|
return {
|
|
socialTurn:row.channel==='social-start'?0:row.channel==='social-reply'?1:row.channel==='social-turn-2'?2:row.channel==='social-turn-3'?3:undefined,
|
|
id:row.id,
|
|
createdAt:row.created_at,
|
|
speakerName:row.speaker_name,
|
|
message:row.message,
|
|
channel:row.channel==='whisper'?'whisper':'say',
|
|
zoneName:row.zone_name,
|
|
receiver:row.receiver,
|
|
selectedPlayer:row.selected_player,
|
|
nearbyBots:String(row.nearby_bots||'').split(',').map((x:string)=>x.trim()).filter(Boolean),
|
|
status:'processing',
|
|
attempts:Number(row.attempts||0)+1
|
|
};
|
|
}
|
|
|
|
export async function completeConversation(db:D1Database,id:string,result:string){
|
|
await ensure(db);
|
|
await db.prepare(`UPDATE conversation_requests
|
|
SET status='completed',result=?,error=NULL,completed_at=?
|
|
WHERE id=?`).bind(result.slice(0,2000),new Date().toISOString(),id).run();
|
|
}
|
|
|
|
export async function failConversation(db:D1Database,id:string,error:string){
|
|
await ensure(db);
|
|
await db.prepare(`UPDATE conversation_requests
|
|
SET status='failed',error=?,completed_at=?
|
|
WHERE id=?`).bind(error.slice(0,2000),new Date().toISOString(),id).run();
|
|
}
|
|
|
|
export async function cleanupConversations(db:D1Database){
|
|
await ensure(db);
|
|
const before=new Date(Date.now()-24*60*60_000).toISOString();
|
|
await db.prepare(`DELETE FROM conversation_requests
|
|
WHERE status IN ('completed','failed') AND completed_at IS NOT NULL AND completed_at<?`)
|
|
.bind(before).run();
|
|
}
|
|
|
|
export async function awaitDelivery(db:D1Database,request:ConversationRequest,botId:string,reply:string,commandId:string){
|
|
await db.prepare("UPDATE conversation_requests SET status='awaiting_delivery',result=? WHERE id=?")
|
|
.bind(JSON.stringify({request,botId,reply,commandId}),request.id).run();
|
|
}
|
|
|
|
export async function pendingDeliveries(db:D1Database){
|
|
await ensure(db);
|
|
const rows=await db.prepare("SELECT result FROM conversation_requests WHERE status='awaiting_delivery' ORDER BY created_at LIMIT 50").all<{result:string}>();
|
|
const deliveries=[];
|
|
for(const row of rows.results){
|
|
const delivery=JSON.parse(row.result) as {request:ConversationRequest;botId:string;reply:string;commandId:string};
|
|
const command=await db.prepare('SELECT status,error FROM ale_commands WHERE id=?').bind(delivery.commandId).first<{status:string;error:string|null}>();
|
|
deliveries.push({...delivery,status:command?.status??'pending',error:command?.error});
|
|
}
|
|
return deliveries;
|
|
}
|
|
|
|
// One autonomous exchange per two-minute bucket; human chat keeps priority.
|
|
export async function queueSocialEncounter(db:D1Database,payload:Record<string,unknown>){
|
|
const a=text(payload,'socialA',40),b=text(payload,'socialB',40);
|
|
const at=Number(payload.observedAt);
|
|
if(!a||!b||a===b||!Number.isFinite(at)||Math.abs(Date.now()/1000-at)>60)return;
|
|
await ensure(db);
|
|
const now=new Date().toISOString();
|
|
const cutoff=new Date(Date.now()-10*60_000).toISOString();
|
|
const id='social-'+Math.floor(Date.now()/120_000);
|
|
await db.prepare(`INSERT OR IGNORE INTO conversation_requests
|
|
(id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts)
|
|
SELECT ?,?,?,?,'social-start',?,?,?,?,'pending',0
|
|
WHERE NOT EXISTS (SELECT 1 FROM conversation_requests WHERE status IN ('pending','processing','awaiting_delivery'))
|
|
AND NOT EXISTS (SELECT 1 FROM conversation_requests WHERE channel LIKE 'social-%' AND created_at>? AND (speaker_name IN (?,?) OR selected_player IN (?,?)))`)
|
|
.bind(id,now,b,'Initiate a brief friendly in-world conversation with your nearby companion.',text(payload,'zoneName',120),a,a,b,cutoff,a,b,a,b).run();
|
|
}
|
|
|
|
export async function queueSocialReply(db:D1Database,request:ConversationRequest,botName:string,reply:string){
|
|
if(request.socialTurn===undefined||request.socialTurn>=3||Date.now()-Date.parse(request.createdAt)>120_000)return;
|
|
// A farewell ends the encounter instead of forcing another response.
|
|
if(request.socialTurn>0&&/\b(bye|goodbye|see you|see ya|catch you later)\b/i.test(reply))return;
|
|
const channel=request.socialTurn===0?'social-reply':`social-turn-${request.socialTurn+1}`;
|
|
await db.prepare(`INSERT OR IGNORE INTO conversation_requests
|
|
(id,created_at,speaker_name,message,channel,zone_name,receiver,selected_player,nearby_bots,status,attempts)
|
|
VALUES (?,?,?,?,?,?,?,?,?,'pending',0)`)
|
|
.bind(request.id+':reply',request.createdAt,botName,reply,channel,request.zoneName,request.speakerName,request.speakerName,botName).run();
|
|
}
|