fix: use passive QQ event replies
This commit is contained in:
@@ -135,9 +135,30 @@ export class AssistantManager implements ConversationRuntime {
|
||||
}
|
||||
|
||||
status(platform: string, chatId: string, userId?: string): Record<string, string | number | boolean> {
|
||||
const chatKey = chatKeyFor(platform, chatId);
|
||||
const proposals = this.proposals.list();
|
||||
const owned = (proposal: Proposal) => Boolean(userId) && proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId;
|
||||
const mine = proposals.filter(owned);
|
||||
const active = this.active;
|
||||
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
|
||||
const awaiting = proposals.filter((proposal) => proposal.status === "awaiting_user_confirmation");
|
||||
const working = proposals.filter((proposal) => proposal.status === "working");
|
||||
const myQueued = mine.filter((proposal) => proposal.status === "queued").length;
|
||||
const myPending = mine.filter((proposal) => proposal.status === "awaiting_user_confirmation").length;
|
||||
const myProposed = mine.filter((proposal) => proposal.status === "proposed").length;
|
||||
const schedulerState = active || working.length > 0 ? "working" : awaiting.length > 0 ? "awaiting_confirmation" : "idle";
|
||||
const blockedReason = awaiting.length > 0
|
||||
? (awaiting.some(owned) ? "your proposal is awaiting your confirmation" : "another proposal is awaiting owner confirmation")
|
||||
: active || working.length > 0
|
||||
? (working.some(owned) || (activeProposal && owned(activeProposal)) ? "your proposal is working" : "another proposal is working")
|
||||
: "none";
|
||||
const nextAction = myPending > 0
|
||||
? "confirm your pending proposal"
|
||||
: myProposed > 0
|
||||
? "confirm your proposed proposal to queue it"
|
||||
: myQueued > 0
|
||||
? (schedulerState === "idle" ? "start_next" : "wait for the current proposal to settle")
|
||||
: "none";
|
||||
return {
|
||||
bot: this.bot.id,
|
||||
agent: this.bot.agent.id,
|
||||
@@ -145,10 +166,15 @@ export class AssistantManager implements ConversationRuntime {
|
||||
assistantSessions: this.assistantWorkers.size,
|
||||
proposals: proposals.length,
|
||||
queuedProposals: proposals.filter((proposal) => proposal.status === "queued").length,
|
||||
workingProposals: proposals.filter((proposal) => proposal.status === "working").length,
|
||||
awaitingConfirmation: proposals.filter((proposal) => proposal.status === "awaiting_user_confirmation").length,
|
||||
workingProposals: working.length,
|
||||
awaitingConfirmation: awaiting.length,
|
||||
workerRunning: Boolean(active?.worker.inFlight),
|
||||
owner: Boolean(userId && activeProposal && activeProposal.ownerChatKey === chatKeyFor(platform, chatId) && activeProposal.requesterUserId === userId)
|
||||
owner: Boolean(userId && activeProposal && owned(activeProposal)),
|
||||
myQueuedProposals: myQueued,
|
||||
myPendingConfirmations: myPending,
|
||||
schedulerState,
|
||||
blockedReason,
|
||||
nextAction
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,8 +214,8 @@ export class AssistantManager implements ConversationRuntime {
|
||||
const parsed = await this.parseAssistantReply(worker, reply);
|
||||
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
|
||||
await this.store.touchBinding(conversationKey);
|
||||
await this.executeActions(chatKeyFor(request.platform, request.chatId), request, parsed.actions);
|
||||
return parsed.reply;
|
||||
const corrections = await this.executeActions(chatKeyFor(request.platform, request.chatId), request, parsed.actions);
|
||||
return corrections.length > 0 ? `${parsed.reply}\n${corrections.join("\n")}` : parsed.reply;
|
||||
} catch (error) {
|
||||
if (worker.isolationViolated) await this.store.deleteBinding(conversationKey).catch(() => undefined);
|
||||
throw error;
|
||||
@@ -203,7 +229,8 @@ export class AssistantManager implements ConversationRuntime {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private async executeActions(chatKey: string, request: ConversationRequest, actions: AssistantAction[]): Promise<void> {
|
||||
private async executeActions(chatKey: string, request: ConversationRequest, actions: AssistantAction[]): Promise<string[]> {
|
||||
const corrections: string[] = [];
|
||||
for (const action of actions) {
|
||||
if (action.type === "create_proposal") {
|
||||
await this.proposals.create({
|
||||
@@ -214,15 +241,33 @@ export class AssistantManager implements ConversationRuntime {
|
||||
requesterUserId: request.userId
|
||||
});
|
||||
} else if (action.type === "confirm") {
|
||||
await this.scheduler(() => this.confirmLocked(chatKey, request.userId, action.id, action.answer));
|
||||
const ok = await this.scheduler(() => this.confirmLocked(chatKey, request.userId, action.id, action.answer));
|
||||
if (!ok) corrections.push("没有可确认的提案;你只能操作自己发起的任务。");
|
||||
} else if (action.type === "start_next") {
|
||||
await this.scheduler(() => this.tryStartLocked(chatKey, request.userId));
|
||||
const started = await this.scheduler(() => this.tryStartLocked(chatKey, request.userId));
|
||||
if (!started) corrections.push(this.startNextCorrection(chatKey, request.userId));
|
||||
} else if (action.type === "cancel") {
|
||||
await this.scheduler(() => this.cancelLocked(chatKey, request.userId, action.id));
|
||||
const ok = await this.scheduler(() => this.cancelLocked(chatKey, request.userId, action.id));
|
||||
if (!ok) corrections.push("没有可取消的提案;你只能操作自己发起的任务。");
|
||||
} else if (action.type === "stop") {
|
||||
await this.scheduler(() => this.stopActiveLocked(chatKey, request.userId));
|
||||
const ok = await this.scheduler(() => this.stopActiveLocked(chatKey, request.userId));
|
||||
if (!ok) corrections.push("没有可停止的任务;你只能操作自己发起的任务。");
|
||||
}
|
||||
}
|
||||
return corrections;
|
||||
}
|
||||
|
||||
private startNextCorrection(chatKey: string, userId: string): string {
|
||||
const queuedMine = this.proposals.list({ status: "queued" })
|
||||
.some((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
|
||||
const kept = queuedMine ? "你的任务已保留在队列里。" : "";
|
||||
if (this.proposals.list({ status: "awaiting_user_confirmation" }).length > 0) {
|
||||
return `还不能开始:还有一个任务在等发起人确认。${kept}`.trim();
|
||||
}
|
||||
if (this.active || this.proposals.list({ status: "working" }).length > 0) {
|
||||
return `还不能开始:还有一个任务正在执行。${kept}`.trim();
|
||||
}
|
||||
return "还不能开始:你没有已确认并排队的任务。";
|
||||
}
|
||||
|
||||
private async confirmLocked(chatKey: string, userId: string, id?: string, answer?: string): Promise<boolean> {
|
||||
@@ -310,15 +355,16 @@ export class AssistantManager implements ConversationRuntime {
|
||||
await this.proposals.update(proposalId, { workerProcessGroup: undefined });
|
||||
}
|
||||
|
||||
private async tryStartLocked(chatKey: string, userId: string): Promise<void> {
|
||||
if (this.active) return;
|
||||
if (this.proposals.list({ status: "working" }).length > 0) return;
|
||||
if (this.proposals.list({ status: "awaiting_user_confirmation" }).length > 0) return;
|
||||
private async tryStartLocked(chatKey: string, userId: string): Promise<boolean> {
|
||||
if (this.active) return false;
|
||||
if (this.proposals.list({ status: "working" }).length > 0) return false;
|
||||
if (this.proposals.list({ status: "awaiting_user_confirmation" }).length > 0) return false;
|
||||
const next = this.proposals.list({ status: "queued" })
|
||||
.find((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
|
||||
if (!next) return;
|
||||
if (!next) return false;
|
||||
await this.proposals.update(next.id, { status: "working", startedAt: Date.now() });
|
||||
this.launchWorker(next.id);
|
||||
return true;
|
||||
}
|
||||
|
||||
private resolveTargetProposal(chatKey: string, userId: string, id: string | undefined, statuses: Proposal["status"][]): Proposal | undefined {
|
||||
@@ -600,11 +646,26 @@ export class AssistantManager implements ConversationRuntime {
|
||||
"[Proposal states]",
|
||||
...this.proposalSummaries(chatKey, request.userId),
|
||||
"",
|
||||
"[Scheduler state]",
|
||||
this.schedulerStateSummary(chatKey, request.userId),
|
||||
"",
|
||||
"[Worker state]",
|
||||
this.workerStateSummary(chatKey, request.userId)
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private schedulerStateSummary(chatKey: string, userId: string): string {
|
||||
const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId;
|
||||
const awaitingOther = this.proposals.list({ status: "awaiting_user_confirmation" }).some((proposal) => !owned(proposal));
|
||||
if (awaitingOther) return "blocked: another proposal is awaiting owner confirmation";
|
||||
const active = this.active;
|
||||
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
|
||||
const workingOther = this.proposals.list({ status: "working" }).some((proposal) => !owned(proposal))
|
||||
|| Boolean(activeProposal && !owned(activeProposal));
|
||||
if (workingOther) return "busy: another proposal is working";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
private proposalSummaries(chatKey: string, userId: string): string[] {
|
||||
const proposals = this.proposals.list()
|
||||
.filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
|
||||
|
||||
+136
-15
@@ -10,16 +10,33 @@ export interface GatewayResult { ok: boolean; reply?: string; ignored?: boolean;
|
||||
interface ChatTarget {
|
||||
target: MessageTarget;
|
||||
adapter: PlatformAdapter;
|
||||
lastInboundMessageId?: string;
|
||||
lastInboundAt?: number;
|
||||
inboundIsGroup: boolean;
|
||||
lastEventDelivery?: "success" | "failed" | "skipped";
|
||||
lastEventError?: string;
|
||||
lastEventAt?: number;
|
||||
}
|
||||
|
||||
export interface GatewayOptions {
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
// QQ passive-reply windows (with safety margin): 5 minutes for group chats, 60 minutes for C2C.
|
||||
const GROUP_PASSIVE_WINDOW_MS = 4.5 * 60_000;
|
||||
const C2C_PASSIVE_WINDOW_MS = 55 * 60_000;
|
||||
|
||||
class ReplyStream {
|
||||
private sequence = 0;
|
||||
private tail = Promise.resolve();
|
||||
|
||||
constructor(private readonly message: IncomingMessage, private readonly adapter: PlatformAdapter) {}
|
||||
constructor(
|
||||
private readonly message: IncomingMessage,
|
||||
private readonly adapter: PlatformAdapter,
|
||||
private readonly nextSequence: () => number | undefined
|
||||
) {}
|
||||
|
||||
enqueue(text: string): Promise<void> {
|
||||
const replySequence = ++this.sequence;
|
||||
enqueue(text: string, onError?: (error: unknown) => void): Promise<void> {
|
||||
const replySequence = this.nextSequence();
|
||||
this.tail = this.tail.then(() => this.adapter.sendMessage({
|
||||
target: {
|
||||
platform: this.message.platform,
|
||||
@@ -30,8 +47,9 @@ class ReplyStream {
|
||||
text,
|
||||
replyTo: this.message.messageId,
|
||||
replySequence
|
||||
})).catch(() => {
|
||||
})).catch((error: unknown) => {
|
||||
console.error(`Gateway delivery send failed (platform=${this.message.platform})`);
|
||||
onError?.(error);
|
||||
});
|
||||
return this.tail;
|
||||
}
|
||||
@@ -39,13 +57,21 @@ class ReplyStream {
|
||||
|
||||
export class Gateway {
|
||||
private readonly chatTargets = new Map<string, ChatTarget>();
|
||||
private readonly pendingEvents = new Map<string, string[]>();
|
||||
// QQ deduplicates passive replies by msg_id + msg_seq: all sends referencing the same inbound
|
||||
// message (normal replies, events, redelivered pending events) share one counter per chat+messageId.
|
||||
private readonly messageSequences = new Map<string, Map<string, number>>();
|
||||
private readonly now: () => number;
|
||||
private closed = false;
|
||||
readonly commandRouter = new CommandRouter();
|
||||
|
||||
constructor(
|
||||
private readonly policy: GatewayPolicy,
|
||||
private readonly runtime: ConversationRuntime
|
||||
) {}
|
||||
private readonly runtime: ConversationRuntime,
|
||||
options: GatewayOptions = {}
|
||||
) {
|
||||
this.now = options.now || Date.now;
|
||||
}
|
||||
|
||||
async receive(message: IncomingMessage, adapter: PlatformAdapter, options: { synchronous?: boolean } = {}): Promise<GatewayResult> {
|
||||
const policyError = this.checkPolicy(message);
|
||||
@@ -54,20 +80,29 @@ export class Gateway {
|
||||
return { ok: true, ignored: true, error: policyError };
|
||||
}
|
||||
|
||||
this.chatTargets.set(chatKeyFor(message.platform, message.chatId), {
|
||||
const chatKey = chatKeyFor(message.platform, message.chatId);
|
||||
const previous = this.chatTargets.get(chatKey);
|
||||
this.chatTargets.set(chatKey, {
|
||||
target: {
|
||||
platform: message.platform,
|
||||
chatId: message.chatId,
|
||||
userId: message.userId,
|
||||
raw: message.raw
|
||||
},
|
||||
adapter
|
||||
adapter,
|
||||
lastInboundMessageId: message.messageId,
|
||||
lastInboundAt: this.now(),
|
||||
inboundIsGroup: Boolean(message.isGroup),
|
||||
lastEventDelivery: previous?.lastEventDelivery,
|
||||
lastEventError: previous?.lastEventError,
|
||||
lastEventAt: previous?.lastEventAt
|
||||
});
|
||||
await this.flushPendingEvents(chatKey, message, adapter, options);
|
||||
|
||||
const command = this.commandRouter.parse(message.text);
|
||||
if (command) {
|
||||
const result = await this.executeCommandResult(command, message);
|
||||
if (!options.synchronous) await new ReplyStream(message, adapter).enqueue(result.reply || "");
|
||||
if (!options.synchronous) await this.replyStream(chatKey, message, adapter).enqueue(result.reply || "");
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -81,12 +116,12 @@ export class Gateway {
|
||||
text: message.text,
|
||||
messageId: message.messageId
|
||||
})).text;
|
||||
await new ReplyStream(message, adapter).enqueue(reply);
|
||||
await this.replyStream(chatKey, message, adapter).enqueue(reply);
|
||||
return { ok: true, reply };
|
||||
} catch (error) {
|
||||
const errorText = error instanceof Error ? error.message : String(error);
|
||||
const reply = `Agent error: ${errorText}`;
|
||||
await new ReplyStream(message, adapter).enqueue(reply);
|
||||
await this.replyStream(chatKey, message, adapter).enqueue(reply);
|
||||
return { ok: false, error: errorText, reply };
|
||||
}
|
||||
}
|
||||
@@ -95,13 +130,89 @@ export class Gateway {
|
||||
if (this.closed) return;
|
||||
const entry = this.chatTargets.get(chatKey);
|
||||
if (!entry) return;
|
||||
const windowMs = entry.inboundIsGroup ? GROUP_PASSIVE_WINDOW_MS : C2C_PASSIVE_WINDOW_MS;
|
||||
const freshInbound = entry.lastInboundMessageId
|
||||
&& entry.lastInboundAt !== undefined
|
||||
&& this.now() - entry.lastInboundAt <= windowMs;
|
||||
if (!freshInbound) {
|
||||
// No fresh passive window and proactive messages may be unauthorized: skip and remind on the next inbound.
|
||||
this.recordEventDelivery(chatKey, "skipped", "no fresh passive window");
|
||||
this.queuePendingEvent(chatKey, text);
|
||||
console.log(`Gateway event skipped: no fresh passive window (platform=${entry.target.platform})`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await entry.adapter.sendMessage({ target: entry.target, text });
|
||||
} catch {
|
||||
await entry.adapter.sendMessage({
|
||||
target: entry.target,
|
||||
text,
|
||||
replyTo: entry.lastInboundMessageId,
|
||||
replySequence: this.nextReplySequence(chatKey, entry.lastInboundMessageId)
|
||||
});
|
||||
this.recordEventDelivery(chatKey, "success");
|
||||
} catch (error) {
|
||||
this.recordEventDelivery(chatKey, "failed", safeEventError(error));
|
||||
this.queuePendingEvent(chatKey, text);
|
||||
console.error(`Gateway event send failed (platform=${entry.target.platform})`);
|
||||
}
|
||||
}
|
||||
|
||||
private recordEventDelivery(chatKey: string, delivery: "success" | "failed" | "skipped", error?: string): void {
|
||||
const entry = this.chatTargets.get(chatKey);
|
||||
if (!entry) return;
|
||||
entry.lastEventDelivery = delivery;
|
||||
entry.lastEventError = error;
|
||||
entry.lastEventAt = this.now();
|
||||
}
|
||||
|
||||
private queuePendingEvent(chatKey: string, text: string): void {
|
||||
const pending = this.pendingEvents.get(chatKey) || [];
|
||||
pending.push(text);
|
||||
this.pendingEvents.set(chatKey, pending);
|
||||
}
|
||||
|
||||
private async flushPendingEvents(chatKey: string, message: IncomingMessage, adapter: PlatformAdapter, options: { synchronous?: boolean }): Promise<void> {
|
||||
if (options.synchronous) return;
|
||||
const pending = this.pendingEvents.get(chatKey);
|
||||
if (!pending || pending.length === 0) return;
|
||||
this.pendingEvents.delete(chatKey);
|
||||
const stream = this.replyStream(chatKey, message, adapter);
|
||||
for (const text of pending) {
|
||||
await stream.enqueue(text, (error) => {
|
||||
// Redelivery failed: keep the event queued for the next inbound and record the failure.
|
||||
this.recordEventDelivery(chatKey, "failed", safeEventError(error));
|
||||
this.queuePendingEvent(chatKey, text);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private replyStream(chatKey: string, message: IncomingMessage, adapter: PlatformAdapter): ReplyStream {
|
||||
return new ReplyStream(message, adapter, () => this.nextReplySequence(chatKey, message.messageId));
|
||||
}
|
||||
|
||||
private nextReplySequence(chatKey: string, messageId: string | undefined): number | undefined {
|
||||
if (!messageId) return undefined;
|
||||
let sequences = this.messageSequences.get(chatKey);
|
||||
if (!sequences) {
|
||||
sequences = new Map();
|
||||
this.messageSequences.set(chatKey, sequences);
|
||||
}
|
||||
const next = (sequences.get(messageId) || 0) + 1;
|
||||
// Re-set to refresh insertion order, then bound memory to the most recent message ids per chat.
|
||||
sequences.delete(messageId);
|
||||
sequences.set(messageId, next);
|
||||
while (sequences.size > 8) sequences.delete(sequences.keys().next().value!);
|
||||
return next;
|
||||
}
|
||||
|
||||
private eventStatus(chatKey: string): Record<string, string> {
|
||||
const entry = this.chatTargets.get(chatKey);
|
||||
return {
|
||||
lastEventDelivery: entry?.lastEventDelivery || "none",
|
||||
lastEventError: entry?.lastEventError || "none",
|
||||
lastEventAt: entry?.lastEventAt ? new Date(entry.lastEventAt).toISOString() : "never"
|
||||
};
|
||||
}
|
||||
|
||||
stats(): ReturnType<ConversationRuntime["stats"]> {
|
||||
return this.runtime.stats();
|
||||
}
|
||||
@@ -140,7 +251,8 @@ export class Gateway {
|
||||
case "help": return HELP_TEXT;
|
||||
case "status": {
|
||||
const status = this.runtime.status(message.platform, message.chatId, message.userId);
|
||||
return `OK\n${Object.entries(status).map(([key, value]) => `${key}=${value}`).join("\n")}`;
|
||||
const combined = { ...status, ...this.eventStatus(chatKeyFor(message.platform, message.chatId)) };
|
||||
return `OK\n${Object.entries(combined).map(([key, value]) => `${key}=${value}`).join("\n")}`;
|
||||
}
|
||||
case "list": {
|
||||
if (!this.runtime.listProposals) return "当前运行时不支持列出提案。";
|
||||
@@ -170,6 +282,15 @@ export class Gateway {
|
||||
|
||||
}
|
||||
|
||||
function safeEventError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const http = /HTTP (\d{3})/.exec(message);
|
||||
const code = /code=(\d+)/.exec(message);
|
||||
const parts = [http ? `HTTP ${http[1]}` : "", code ? `QQ code ${code[1]}` : ""].filter(Boolean);
|
||||
if (parts.length > 0) return parts.join(" ");
|
||||
return error instanceof Error ? error.name : "error";
|
||||
}
|
||||
|
||||
const HELP_TEXT = [
|
||||
"直接用自然语言告诉我你要做什么即可,我会自己判断并处理。",
|
||||
"兜底命令:",
|
||||
|
||||
@@ -66,17 +66,24 @@ export class QqAdapter implements PlatformAdapter {
|
||||
const path = isGroup ? `/v2/groups/${encodeURIComponent(targetId)}/messages` : `/v2/users/${encodeURIComponent(targetId)}/messages`;
|
||||
console.log(`QQ send ${isGroup ? "group" : "user"} message length=${message.text.length}`);
|
||||
|
||||
// Passive replies reference the inbound message; proactive messages must send neither msg_id nor msg_seq.
|
||||
const body: Record<string, unknown> = { content: message.text };
|
||||
if (message.replyTo) {
|
||||
body.msg_id = message.replyTo;
|
||||
body.msg_seq = message.replySequence ?? 1;
|
||||
}
|
||||
const response = await fetch(`https://api.sgroup.qq.com${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `QQBot ${token}`,
|
||||
"Content-Type": "application/json; charset=utf-8"
|
||||
},
|
||||
body: JSON.stringify({ content: message.text, msg_id: message.replyTo, msg_seq: message.replySequence ?? 1 })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!response.ok) throw new Error(`QQ send failed: HTTP ${response.status}`);
|
||||
const data = await response.json() as QqSendMessageResponse;
|
||||
if (data.code && data.code !== 0) throw new Error(`QQ send failed: ${data.code} ${data.message || ""}`.trim());
|
||||
const data = await response.json().catch(() => undefined) as QqSendMessageResponse | undefined;
|
||||
// Keep only safe error details (HTTP status / QQ code / QQ message); never log the request body.
|
||||
if (!response.ok) throw new Error(`QQ send failed: HTTP ${response.status}${qqErrorDetail(data)}`);
|
||||
if (data?.code && data.code !== 0) throw new Error(`QQ send failed:${qqErrorDetail(data)}`);
|
||||
}
|
||||
|
||||
private handleValidation(data: QqWebhookEventData | undefined): WebhookResponse {
|
||||
@@ -141,6 +148,12 @@ export class QqAdapter implements PlatformAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
function qqErrorDetail(data: QqSendMessageResponse | undefined): string {
|
||||
if (!data) return "";
|
||||
const parts = [data.code ? `code=${data.code}` : "", data.message || ""].filter(Boolean);
|
||||
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
|
||||
}
|
||||
|
||||
function userIdFor(data: QqWebhookEventData, isGroup: boolean): string | undefined {
|
||||
if (isGroup) return data.author?.member_openid || data.member_openid || data.author?.user_openid || data.user_openid || data.author?.id;
|
||||
return data.author?.user_openid || data.user_openid || data.author?.id || data.member_openid || data.author?.member_openid;
|
||||
|
||||
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
||||
import type { AppConfig, BotConfig } from "../config.js";
|
||||
import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
|
||||
|
||||
const BOOTSTRAP_SCHEMA_VERSION = 5;
|
||||
const BOOTSTRAP_SCHEMA_VERSION = 6;
|
||||
|
||||
export interface ResolvedBot extends BotConfig {
|
||||
loadedSkills: LoadedSkill[];
|
||||
@@ -45,6 +45,7 @@ function buildAssistantBootstrap(bot: BotConfig): string {
|
||||
"Every reply must end with exactly one hidden action envelope: <GORI_ASSISTANT_ACTION_V1>{\"reply\":\"...\",\"actions\":[...]}</GORI_ASSISTANT_ACTION_V1>. Put the user-facing text in the JSON \"reply\" field, not outside the envelope.",
|
||||
"Supported actions: {\"type\":\"create_proposal\",\"title\":\"...\",\"goal\":\"...\",\"steps\":[\"...\"]}; {\"type\":\"confirm\",\"id\":\"optional\",\"answer\":\"optional\"}; {\"type\":\"start_next\"}; {\"type\":\"cancel\",\"id\":\"optional\"}; {\"type\":\"stop\"}. Use an empty actions array when no state change is needed.",
|
||||
"A proposal only starts after the user confirms it. Confirming a proposal whose worker reported SUCCESS marks it completed; confirming a FAILED proposal marks it failed; to retry a failed proposal, confirm with answer \"retry\". When a worker asks a question (NEEDS_CONFIRMATION), confirm with the user's answer to continue the same worker. \"stop\" terminates the active worker and cancels its proposal; \"start_next\" starts the oldest confirmed queued proposal. Never invent other actions or statuses.",
|
||||
"Never claim an action has already taken effect. The runtime executes your actions after your reply and appends a correction to your message when something could not be done (for example when start_next is blocked by another proposal). Treat that correction as the truth and use the [Proposal states], [Scheduler state] and [Worker state] sections in each prompt as the only reliable state.",
|
||||
"In a group chat each proposal is owned by the user who requested it: only that user can confirm, stop, or cancel it. If another group member asks you to confirm, stop, or cancel a proposal they did not create, explain that only the proposal's initiator can do that and emit no action.",
|
||||
"When a worker reported SUCCESS and the user says something like \"够了\", \"停止\", \"不用继续\" or \"that's enough\", treat it as accepting the finished work: emit a \"confirm\" action so the proposal settles as completed. Use \"stop\" only to abort work that is still running or waiting on an answer."
|
||||
].join("\n\n");
|
||||
|
||||
Reference in New Issue
Block a user