mirror of
https://gh-proxy.org/https://github.com/klinsmaya/LobsterAI.git
synced 2026-11-08 01:06:44 +08:00
feat(cron): improve scheduled task delivery, channel history sync, and reminder parsing
- Add DingTalk delivery route resolution and caching for scheduled task replies - Extract shared scheduled reminder text parsing into src/common/ - Patch OpenClaw runtime cron delivery inference to support deliveryContext - Improve channel history sync with tail-overlap reconciliation strategy - Harden IM session tracking with DB fallback via ensureTrackedSession - Disable web_search tool for managed sessions and add browser/web_fetch guidance - Guard against stale loadSessions responses in renderer - Normalize scheduled reminder display text in CoworkSessionDetail
This commit is contained in:
@@ -15,5 +15,5 @@
|
||||
"*": ["node_modules/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/main"]
|
||||
"include": ["src/main", "src/common"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
export type ScheduledReminderPrompt = {
|
||||
reminderText: string;
|
||||
currentTime?: string;
|
||||
};
|
||||
|
||||
const SCHEDULED_REMINDER_PREFIX = 'A scheduled reminder has been triggered. The reminder content is:';
|
||||
const SCHEDULED_REMINDER_INTERNAL_INSTRUCTION = 'Handle this reminder internally. Do not relay it to the user unless explicitly requested.';
|
||||
const SCHEDULED_REMINDER_RELAY_INSTRUCTION = 'Please relay this reminder to the user in a helpful and friendly way.';
|
||||
const CURRENT_TIME_PREFIX = 'Current time:';
|
||||
const LEGACY_SYSTEM_LINE_RE = /^System:\s*(?:\[(.+?)\]\s*)?(⏰.+)$/u;
|
||||
const SIMPLE_REMINDER_RE = /^⏰(?:\s|$)/u;
|
||||
|
||||
export function parseScheduledReminderPrompt(text: string): ScheduledReminderPrompt | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith(SCHEDULED_REMINDER_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let remainder = trimmed.slice(SCHEDULED_REMINDER_PREFIX.length).trim();
|
||||
let currentTime: string | undefined;
|
||||
const currentTimeIndex = remainder.lastIndexOf(CURRENT_TIME_PREFIX);
|
||||
if (currentTimeIndex >= 0) {
|
||||
currentTime = remainder.slice(currentTimeIndex + CURRENT_TIME_PREFIX.length).trim() || undefined;
|
||||
remainder = remainder.slice(0, currentTimeIndex).trim();
|
||||
}
|
||||
|
||||
if (remainder.endsWith(SCHEDULED_REMINDER_INTERNAL_INSTRUCTION)) {
|
||||
remainder = remainder.slice(0, -SCHEDULED_REMINDER_INTERNAL_INSTRUCTION.length).trim();
|
||||
} else if (remainder.endsWith(SCHEDULED_REMINDER_RELAY_INSTRUCTION)) {
|
||||
remainder = remainder.slice(0, -SCHEDULED_REMINDER_RELAY_INSTRUCTION.length).trim();
|
||||
}
|
||||
|
||||
if (!remainder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
reminderText: remainder,
|
||||
...(currentTime ? { currentTime } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseLegacyScheduledReminderSystemMessage(text: string): ScheduledReminderPrompt | null {
|
||||
const trimmed = text.trim();
|
||||
const firstLine = trimmed.split(/\r?\n/u, 1)[0]?.trim() ?? '';
|
||||
const match = firstLine.match(LEGACY_SYSTEM_LINE_RE);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rest = trimmed.slice(firstLine.length).trim();
|
||||
const wrappedPrompt = rest ? parseScheduledReminderPrompt(rest) : null;
|
||||
|
||||
return wrappedPrompt ?? {
|
||||
reminderText: match[2].trim(),
|
||||
...(match[1]?.trim() ? { currentTime: match[1].trim() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function isSimpleScheduledReminderText(text: string): boolean {
|
||||
return SIMPLE_REMINDER_RE.test(text.trim());
|
||||
}
|
||||
|
||||
export function parseSimpleScheduledReminderText(text: string): ScheduledReminderPrompt | null {
|
||||
const trimmed = text.trim();
|
||||
if (!isSimpleScheduledReminderText(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
reminderText: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
export function getScheduledReminderDisplayText(text: string): string | null {
|
||||
const prompt = parseScheduledReminderPrompt(text);
|
||||
if (prompt) {
|
||||
return prompt.reminderText;
|
||||
}
|
||||
|
||||
const legacy = parseLegacyScheduledReminderSystemMessage(text);
|
||||
if (legacy) {
|
||||
return legacy.reminderText;
|
||||
}
|
||||
|
||||
const simple = parseSimpleScheduledReminderText(text);
|
||||
if (simple) {
|
||||
return simple.reminderText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { CoworkRuntime, PermissionRequest } from '../libs/agentEngine/types';
|
||||
import type { CoworkStore, CoworkMessage } from '../coworkStore';
|
||||
import type { IMStore } from './imStore';
|
||||
import type { IMMessage, IMPlatform, IMMediaAttachment } from './types';
|
||||
import type { IMMessage, IMPlatform, IMMediaAttachment, IMSessionMapping } from './types';
|
||||
import { buildIMMediaInstruction } from './imMediaInstruction';
|
||||
import { analyzeIMReply } from './imReplyGuard';
|
||||
import {
|
||||
@@ -108,14 +108,32 @@ export class IMCoworkHandler extends EventEmitter {
|
||||
if (!session) {
|
||||
continue;
|
||||
}
|
||||
this.imSessionIds.add(mapping.coworkSessionId);
|
||||
this.sessionConversationMap.set(mapping.coworkSessionId, {
|
||||
conversationId: mapping.imConversationId,
|
||||
platform: mapping.platform,
|
||||
});
|
||||
this.trackSessionMapping(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
private trackSessionMapping(mapping: IMSessionMapping): void {
|
||||
this.imSessionIds.add(mapping.coworkSessionId);
|
||||
this.sessionConversationMap.set(mapping.coworkSessionId, {
|
||||
conversationId: mapping.imConversationId,
|
||||
platform: mapping.platform,
|
||||
});
|
||||
}
|
||||
|
||||
private ensureTrackedSession(sessionId: string): boolean {
|
||||
if (this.imSessionIds.has(sessionId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const mapping = this.imStore.getSessionMappingByCoworkSessionId(sessionId);
|
||||
if (!mapping) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.trackSessionMapping(mapping);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners for CoworkRuntime
|
||||
*/
|
||||
@@ -275,11 +293,7 @@ export class IMCoworkHandler extends EventEmitter {
|
||||
this.coworkRuntime.stopSession(existing.coworkSessionId);
|
||||
} else {
|
||||
this.imStore.updateSessionLastActive(imConversationId, platform);
|
||||
this.imSessionIds.add(existing.coworkSessionId);
|
||||
this.sessionConversationMap.set(existing.coworkSessionId, {
|
||||
conversationId: imConversationId,
|
||||
platform,
|
||||
});
|
||||
this.trackSessionMapping(existing);
|
||||
return existing.coworkSessionId;
|
||||
}
|
||||
}
|
||||
@@ -316,12 +330,8 @@ export class IMCoworkHandler extends EventEmitter {
|
||||
);
|
||||
|
||||
// Save mapping
|
||||
this.imStore.createSessionMapping(imConversationId, platform, session.id);
|
||||
this.imSessionIds.add(session.id);
|
||||
this.sessionConversationMap.set(session.id, {
|
||||
conversationId: imConversationId,
|
||||
platform,
|
||||
});
|
||||
const mapping = this.imStore.createSessionMapping(imConversationId, platform, session.id);
|
||||
this.trackSessionMapping(mapping);
|
||||
|
||||
return session.id;
|
||||
}
|
||||
@@ -525,7 +535,7 @@ export class IMCoworkHandler extends EventEmitter {
|
||||
*/
|
||||
private handleMessage(sessionId: string, message: CoworkMessage): void {
|
||||
// Only process messages from IM sessions
|
||||
if (!this.imSessionIds.has(sessionId)) return;
|
||||
if (!this.ensureTrackedSession(sessionId)) return;
|
||||
|
||||
const accumulator = this.messageAccumulators.get(sessionId) ?? this.ensureBackgroundAccumulator(sessionId);
|
||||
if (accumulator) {
|
||||
@@ -538,7 +548,7 @@ export class IMCoworkHandler extends EventEmitter {
|
||||
*/
|
||||
private handleMessageUpdate(sessionId: string, messageId: string, content: string): void {
|
||||
// Only process updates from IM sessions
|
||||
if (!this.imSessionIds.has(sessionId)) return;
|
||||
if (!this.ensureTrackedSession(sessionId)) return;
|
||||
|
||||
const accumulator = this.messageAccumulators.get(sessionId);
|
||||
if (accumulator) {
|
||||
@@ -749,7 +759,7 @@ export class IMCoworkHandler extends EventEmitter {
|
||||
*/
|
||||
private handlePermissionRequest(sessionId: string, request: PermissionRequest): void {
|
||||
// Only process permission requests from IM sessions
|
||||
if (!this.imSessionIds.has(sessionId)) return;
|
||||
if (!this.ensureTrackedSession(sessionId)) return;
|
||||
const conversation = this.sessionConversationMap.get(sessionId);
|
||||
if (!conversation) {
|
||||
this.coworkRuntime.respondToPermission(request.requestId, {
|
||||
@@ -803,7 +813,7 @@ export class IMCoworkHandler extends EventEmitter {
|
||||
*/
|
||||
private handleComplete(sessionId: string): void {
|
||||
// Only process complete events from IM sessions
|
||||
if (!this.imSessionIds.has(sessionId)) return;
|
||||
if (!this.ensureTrackedSession(sessionId)) return;
|
||||
|
||||
this.clearPendingPermissionsBySessionId(sessionId);
|
||||
const accumulator = this.messageAccumulators.get(sessionId);
|
||||
@@ -856,7 +866,7 @@ export class IMCoworkHandler extends EventEmitter {
|
||||
*/
|
||||
private handleError(sessionId: string, error: string): void {
|
||||
// Only process error events from IM sessions
|
||||
if (!this.imSessionIds.has(sessionId)) return;
|
||||
if (!this.ensureTrackedSession(sessionId)) return;
|
||||
|
||||
this.clearPendingPermissionsBySessionId(sessionId);
|
||||
const accumulator = this.messageAccumulators.get(sessionId);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
buildManagedSessionKey,
|
||||
DEFAULT_MANAGED_AGENT_ID,
|
||||
} from '../libs/openclawChannelSessionSync';
|
||||
|
||||
export interface OpenClawDeliveryRoute {
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
type OpenClawSessionListEntry = {
|
||||
key?: unknown;
|
||||
deliveryContext?: unknown;
|
||||
lastChannel?: unknown;
|
||||
lastTo?: unknown;
|
||||
lastAccountId?: unknown;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
};
|
||||
|
||||
const readTrimmedString = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized || undefined;
|
||||
};
|
||||
|
||||
export function extractOpenClawDeliveryRoute(entry: unknown): OpenClawDeliveryRoute | null {
|
||||
if (!isRecord(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typedEntry = entry as OpenClawSessionListEntry;
|
||||
const deliveryContext = isRecord(typedEntry.deliveryContext)
|
||||
? typedEntry.deliveryContext
|
||||
: null;
|
||||
|
||||
const channel = readTrimmedString(deliveryContext?.channel) ?? readTrimmedString(typedEntry.lastChannel);
|
||||
const to = readTrimmedString(deliveryContext?.to) ?? readTrimmedString(typedEntry.lastTo);
|
||||
const accountId = readTrimmedString(deliveryContext?.accountId) ?? readTrimmedString(typedEntry.lastAccountId);
|
||||
|
||||
if (!channel || !to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
channel,
|
||||
to,
|
||||
...(accountId ? { accountId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function findOpenClawDeliveryRouteForSession(
|
||||
sessionKey: string,
|
||||
sessions: unknown[],
|
||||
): OpenClawDeliveryRoute | null {
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedSessionKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const entry of sessions) {
|
||||
if (!isRecord(entry)) {
|
||||
continue;
|
||||
}
|
||||
const entryKey = readTrimmedString(entry.key);
|
||||
if (entryKey !== normalizedSessionKey) {
|
||||
continue;
|
||||
}
|
||||
return extractOpenClawDeliveryRoute(entry);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveOpenClawDeliveryRouteForSessionKeys(
|
||||
sessionKeys: string[],
|
||||
sessions: unknown[],
|
||||
): { sessionKey: string; route: OpenClawDeliveryRoute } | null {
|
||||
const seen = new Set<string>();
|
||||
for (const rawSessionKey of sessionKeys) {
|
||||
const sessionKey = rawSessionKey.trim();
|
||||
if (!sessionKey || seen.has(sessionKey)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(sessionKey);
|
||||
|
||||
const route = findOpenClawDeliveryRouteForSession(sessionKey, sessions);
|
||||
if (route) {
|
||||
return { sessionKey, route };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveManagedSessionDeliveryRoute(
|
||||
coworkSessionId: string,
|
||||
sessions: unknown[],
|
||||
): { sessionKey: string; route: OpenClawDeliveryRoute } | null {
|
||||
return resolveOpenClawDeliveryRouteForSessionKeys([buildManagedSessionKey(coworkSessionId)], sessions);
|
||||
}
|
||||
|
||||
export function buildDingTalkSendParamsFromRoute(
|
||||
route: OpenClawDeliveryRoute,
|
||||
): { target: string; accountId?: string } | null {
|
||||
const channel = route.channel.trim().toLowerCase();
|
||||
if (channel !== 'dingtalk-connector' && channel !== 'dingtalk') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
target: route.to,
|
||||
...(route.accountId ? { accountId: route.accountId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDingTalkSessionKeyCandidates(conversationId: string): string[] {
|
||||
const normalizedConversationId = conversationId.trim();
|
||||
if (!normalizedConversationId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
`agent:${DEFAULT_MANAGED_AGENT_ID}:openai-user:dingtalk-connector:${normalizedConversationId}`,
|
||||
`agent:${DEFAULT_MANAGED_AGENT_ID}:dingtalk-connector:${normalizedConversationId}`,
|
||||
`dingtalk-connector:${normalizedConversationId}`,
|
||||
];
|
||||
}
|
||||
@@ -18,6 +18,13 @@ import type {
|
||||
ParsedIMScheduledTaskRequest,
|
||||
} from './imScheduledTaskHandler';
|
||||
import { createIMScheduledTaskRequestDetector } from './imScheduledTaskHandler';
|
||||
import {
|
||||
buildDingTalkSessionKeyCandidates,
|
||||
buildDingTalkSendParamsFromRoute,
|
||||
type OpenClawDeliveryRoute,
|
||||
resolveManagedSessionDeliveryRoute,
|
||||
resolveOpenClawDeliveryRouteForSessionKeys,
|
||||
} from './imDeliveryRoute';
|
||||
import { fetchJsonWithTimeout } from './http';
|
||||
import {
|
||||
IMGatewayConfig,
|
||||
@@ -34,6 +41,18 @@ import type { CoworkStore } from '../coworkStore';
|
||||
const CONNECTIVITY_TIMEOUT_MS = 10_000;
|
||||
const INBOUND_ACTIVITY_WARN_AFTER_MS = 2 * 60 * 1000;
|
||||
|
||||
type GatewayClientLike = {
|
||||
request: <T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
opts?: { expectFinal?: boolean },
|
||||
) => Promise<T>;
|
||||
};
|
||||
|
||||
interface OpenClawSessionsListResult {
|
||||
sessions?: unknown[];
|
||||
}
|
||||
|
||||
interface TelegramGetMeResponse {
|
||||
ok?: boolean;
|
||||
result?: {
|
||||
@@ -54,6 +73,9 @@ export interface IMGatewayManagerOptions {
|
||||
isOpenClawEngine?: () => boolean;
|
||||
syncOpenClawConfig?: () => Promise<void>;
|
||||
ensureOpenClawGatewayConnected?: () => Promise<void>;
|
||||
getOpenClawGatewayClient?: () => GatewayClientLike | null;
|
||||
ensureOpenClawGatewayReady?: () => Promise<void>;
|
||||
getOpenClawSessionKeysForCoworkSession?: (sessionId: string) => string[];
|
||||
createScheduledTask?: (params: {
|
||||
sessionId: string;
|
||||
message: IMMessage;
|
||||
@@ -73,6 +95,9 @@ export class IMGatewayManager extends EventEmitter {
|
||||
private isOpenClawEngine: (() => boolean) | null = null;
|
||||
private syncOpenClawConfig: (() => Promise<void>) | null = null;
|
||||
private ensureOpenClawGatewayConnected: (() => Promise<void>) | null = null;
|
||||
private getOpenClawGatewayClient: (() => GatewayClientLike | null) | null = null;
|
||||
private ensureOpenClawGatewayReady: (() => Promise<void>) | null = null;
|
||||
private getOpenClawSessionKeysForCoworkSession: ((sessionId: string) => string[]) | null = null;
|
||||
private createScheduledTask:
|
||||
| ((params: {
|
||||
sessionId: string;
|
||||
@@ -104,6 +129,9 @@ export class IMGatewayManager extends EventEmitter {
|
||||
this.isOpenClawEngine = options?.isOpenClawEngine ?? null;
|
||||
this.syncOpenClawConfig = options?.syncOpenClawConfig ?? null;
|
||||
this.ensureOpenClawGatewayConnected = options?.ensureOpenClawGatewayConnected ?? null;
|
||||
this.getOpenClawGatewayClient = options?.getOpenClawGatewayClient ?? null;
|
||||
this.ensureOpenClawGatewayReady = options?.ensureOpenClawGatewayReady ?? null;
|
||||
this.getOpenClawSessionKeysForCoworkSession = options?.getOpenClawSessionKeysForCoworkSession ?? null;
|
||||
this.createScheduledTask = options?.createScheduledTask ?? null;
|
||||
|
||||
// Forward gateway events
|
||||
@@ -1614,6 +1642,27 @@ export class IMGatewayManager extends EventEmitter {
|
||||
async sendConversationReply(platform: IMPlatform, conversationId: string, text: string): Promise<boolean> {
|
||||
try {
|
||||
switch (platform) {
|
||||
case 'dingtalk': {
|
||||
const target = await this.resolveDingTalkConversationReplyTarget(conversationId)
|
||||
?? this.parseDingTalkConversationTarget(conversationId);
|
||||
if (!target) {
|
||||
console.warn(`[IMGatewayManager] Cannot resolve DingTalk target from conversationId: ${conversationId}`);
|
||||
return false;
|
||||
}
|
||||
await this.requestOpenClawGateway('dingtalk-connector.send', {
|
||||
...(target.accountId ? { accountId: target.accountId } : {}),
|
||||
target: target.target,
|
||||
content: text,
|
||||
useAICard: false,
|
||||
fallbackToNormal: true,
|
||||
});
|
||||
this.cacheConversationReplyRoute('dingtalk', conversationId, {
|
||||
channel: 'dingtalk-connector',
|
||||
to: target.target,
|
||||
...(target.accountId ? { accountId: target.accountId } : {}),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'nim':
|
||||
await this.nimGateway.sendConversationNotification(conversationId, text);
|
||||
return true;
|
||||
@@ -1629,6 +1678,235 @@ export class IMGatewayManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
async primeConversationReplyRoute(
|
||||
platform: IMPlatform,
|
||||
conversationId: string,
|
||||
coworkSessionId: string,
|
||||
): Promise<void> {
|
||||
if (platform !== 'dingtalk') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const lookup = await this.lookupDingTalkConversationReplyRoute(conversationId, coworkSessionId);
|
||||
const resolved = lookup?.resolved;
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cacheConversationReplyRoute('dingtalk', conversationId, resolved.route);
|
||||
const sendParams = buildDingTalkSendParamsFromRoute(resolved.route);
|
||||
console.log('[IMGatewayManager] Primed DingTalk reply route', JSON.stringify({
|
||||
conversationId,
|
||||
coworkSessionId: lookup.coworkSessionId,
|
||||
sessionKey: resolved.sessionKey,
|
||||
channel: resolved.route.channel,
|
||||
target: sendParams?.target ?? resolved.route.to,
|
||||
accountId: sendParams?.accountId ?? resolved.route.accountId ?? null,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
`[IMGatewayManager] Failed to prime DingTalk reply route for ${conversationId}:`,
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveDingTalkConversationReplyTarget(
|
||||
conversationId: string,
|
||||
): Promise<{ accountId?: string; target: string } | null> {
|
||||
let lookup: Awaited<ReturnType<IMGatewayManager['lookupDingTalkConversationReplyRoute']>> = null;
|
||||
try {
|
||||
lookup = await this.lookupDingTalkConversationReplyRoute(conversationId);
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
`[IMGatewayManager] Failed to query OpenClaw DingTalk reply route for ${conversationId}:`,
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
|
||||
if (!lookup?.resolved) {
|
||||
if (lookup) {
|
||||
console.warn(
|
||||
`[IMGatewayManager] No OpenClaw delivery route found for DingTalk session ${lookup.coworkSessionId}`,
|
||||
JSON.stringify({
|
||||
conversationId,
|
||||
candidateSessionKeys: lookup.candidateSessionKeys,
|
||||
dingtalkSessionKeys: lookup.dingtalkSessionKeys,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const cachedRoute = this.imStore.getConversationReplyRoute('dingtalk', conversationId);
|
||||
if (cachedRoute) {
|
||||
const cachedSendParams = buildDingTalkSendParamsFromRoute(cachedRoute);
|
||||
if (cachedSendParams) {
|
||||
console.log('[IMGatewayManager] Reused cached DingTalk reply route', JSON.stringify({
|
||||
conversationId,
|
||||
channel: cachedRoute.channel,
|
||||
target: cachedSendParams.target,
|
||||
accountId: cachedSendParams.accountId ?? null,
|
||||
}));
|
||||
return cachedSendParams;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const { resolved } = lookup;
|
||||
this.cacheConversationReplyRoute('dingtalk', conversationId, resolved.route);
|
||||
|
||||
const sendParams = buildDingTalkSendParamsFromRoute(resolved.route);
|
||||
if (!sendParams) {
|
||||
console.warn(
|
||||
`[IMGatewayManager] OpenClaw route for ${resolved.sessionKey} is not a DingTalk route: ${resolved.route.channel}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[IMGatewayManager] Resolved DingTalk reply route', JSON.stringify({
|
||||
conversationId,
|
||||
coworkSessionId: lookup.coworkSessionId,
|
||||
sessionKey: resolved.sessionKey,
|
||||
channel: resolved.route.channel,
|
||||
target: sendParams.target,
|
||||
accountId: sendParams.accountId ?? null,
|
||||
}));
|
||||
return sendParams;
|
||||
}
|
||||
|
||||
private async lookupDingTalkConversationReplyRoute(
|
||||
conversationId: string,
|
||||
coworkSessionId?: string,
|
||||
): Promise<{
|
||||
coworkSessionId: string;
|
||||
candidateSessionKeys: string[];
|
||||
dingtalkSessionKeys: string[];
|
||||
resolved: { sessionKey: string; route: OpenClawDeliveryRoute } | null;
|
||||
} | null> {
|
||||
const normalizedCoworkSessionId = coworkSessionId?.trim()
|
||||
|| this.imStore.getSessionMapping(conversationId, 'dingtalk')?.coworkSessionId
|
||||
|| '';
|
||||
if (!normalizedCoworkSessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await this.requestOpenClawGateway<OpenClawSessionsListResult>('sessions.list', {
|
||||
includeGlobal: true,
|
||||
includeUnknown: true,
|
||||
limit: 200,
|
||||
});
|
||||
const sessions = Array.isArray(result?.sessions) ? result.sessions : [];
|
||||
const candidateSessionKeys = [
|
||||
...(this.getOpenClawSessionKeysForCoworkSession?.(normalizedCoworkSessionId) ?? []),
|
||||
...buildDingTalkSessionKeyCandidates(conversationId),
|
||||
];
|
||||
|
||||
return {
|
||||
coworkSessionId: normalizedCoworkSessionId,
|
||||
candidateSessionKeys,
|
||||
dingtalkSessionKeys: this.collectSessionKeysByChannel(sessions, 'dingtalk-connector'),
|
||||
resolved: resolveOpenClawDeliveryRouteForSessionKeys(candidateSessionKeys, sessions)
|
||||
?? resolveManagedSessionDeliveryRoute(normalizedCoworkSessionId, sessions),
|
||||
};
|
||||
}
|
||||
|
||||
private cacheConversationReplyRoute(
|
||||
platform: IMPlatform,
|
||||
conversationId: string,
|
||||
route: OpenClawDeliveryRoute,
|
||||
): void {
|
||||
this.imStore.setConversationReplyRoute(platform, conversationId, route);
|
||||
}
|
||||
|
||||
private collectSessionKeysByChannel(sessions: unknown[], channel: string): string[] {
|
||||
const normalizedChannel = channel.trim().toLowerCase();
|
||||
const matches: string[] = [];
|
||||
for (const entry of sessions) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
continue;
|
||||
}
|
||||
const record = entry as Record<string, unknown>;
|
||||
const key = typeof record.key === 'string' ? record.key.trim() : '';
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const deliveryContext = record.deliveryContext;
|
||||
const deliveryChannel = deliveryContext && typeof deliveryContext === 'object' && !Array.isArray(deliveryContext)
|
||||
? (typeof (deliveryContext as Record<string, unknown>).channel === 'string'
|
||||
? ((deliveryContext as Record<string, unknown>).channel as string)
|
||||
: undefined)
|
||||
: undefined;
|
||||
const lastChannel = typeof record.lastChannel === 'string' ? record.lastChannel : undefined;
|
||||
const routeChannel = (deliveryChannel ?? lastChannel ?? '').trim().toLowerCase();
|
||||
if (routeChannel !== normalizedChannel && !key.toLowerCase().includes(normalizedChannel)) {
|
||||
continue;
|
||||
}
|
||||
matches.push(key);
|
||||
if (matches.length >= 12) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
private parseDingTalkConversationTarget(
|
||||
conversationId: string,
|
||||
): { accountId?: string; target: string } | null {
|
||||
const parts = conversationId.split(':').filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let accountId = parts[0]?.trim();
|
||||
if (!accountId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The dingtalk-connector plugin uses "__default__" as the internal account
|
||||
// alias in conversationIds. The send API expects the actual clientId, so
|
||||
// resolve the alias from the persisted DingTalk config.
|
||||
if (accountId === '__default__') {
|
||||
const dtConfig = this.imStore.getDingTalkOpenClawConfig();
|
||||
if (dtConfig.clientId) {
|
||||
accountId = dtConfig.clientId;
|
||||
}
|
||||
}
|
||||
|
||||
if ((parts[1] === 'user' || parts[1] === 'group') && parts[2]) {
|
||||
return {
|
||||
accountId,
|
||||
target: `${parts[1]}:${parts.slice(2).join(':')}`,
|
||||
};
|
||||
}
|
||||
|
||||
const senderId = parts[1]?.trim();
|
||||
if (!senderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accountId,
|
||||
target: `user:${senderId}`,
|
||||
};
|
||||
}
|
||||
|
||||
private async requestOpenClawGateway<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
): Promise<T> {
|
||||
let client = this.getOpenClawGatewayClient?.() ?? null;
|
||||
if (!client) {
|
||||
await this.ensureOpenClawGatewayReady?.();
|
||||
client = this.getOpenClawGatewayClient?.() ?? null;
|
||||
}
|
||||
if (!client) {
|
||||
throw new Error('OpenClaw gateway client is unavailable.');
|
||||
}
|
||||
return client.request<T>(method, params);
|
||||
}
|
||||
|
||||
private resolveFeishuDomain(domain: string, Lark: any): any {
|
||||
if (domain === 'lark') return Lark.Domain.Lark;
|
||||
if (domain === 'feishu') return Lark.Domain.Feishu;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { IMMediaAttachment, IMMessage } from './types';
|
||||
import { IMChatHandler } from './imChatHandler';
|
||||
import { buildOpenClawLocalTimeContextPrompt } from '../libs/openclawLocalTimeContextPrompt';
|
||||
import {
|
||||
parseSimpleScheduledReminderText,
|
||||
parseLegacyScheduledReminderSystemMessage,
|
||||
parseScheduledReminderPrompt,
|
||||
} from '../../common/scheduledReminderText';
|
||||
|
||||
function pad(value: number): string {
|
||||
return String(value).padStart(2, '0');
|
||||
@@ -269,9 +274,12 @@ export function createIMScheduledTaskRequestDetector(options: {
|
||||
|
||||
export function isReminderSystemTurn(messages: Array<{ type: string; content: string }>): boolean {
|
||||
return messages.some((message) => {
|
||||
const content = typeof message.content === 'string' ? message.content : '';
|
||||
if (message.type === 'system') {
|
||||
return true;
|
||||
return parseSimpleScheduledReminderText(content) !== null
|
||||
|| parseLegacyScheduledReminderSystemMessage(content) !== null;
|
||||
}
|
||||
return /A scheduled reminder has been triggered\.|^System:\s*\[/u.test(message.content);
|
||||
return parseScheduledReminderPrompt(content) !== null
|
||||
|| parseSimpleScheduledReminderText(content) !== null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,6 +29,12 @@ import {
|
||||
DEFAULT_IM_SETTINGS,
|
||||
} from './types';
|
||||
|
||||
interface StoredConversationReplyRoute {
|
||||
channel: string;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export class IMStore {
|
||||
private db: Database;
|
||||
private saveDb: () => void;
|
||||
@@ -529,6 +535,31 @@ export class IMStore {
|
||||
this.setConfigValue(`notification_target:${platform}`, target);
|
||||
}
|
||||
|
||||
getConversationReplyRoute(
|
||||
platform: IMPlatform,
|
||||
conversationId: string,
|
||||
): StoredConversationReplyRoute | null {
|
||||
const normalizedConversationId = conversationId.trim();
|
||||
if (!normalizedConversationId) {
|
||||
return null;
|
||||
}
|
||||
return this.getConfigValue<StoredConversationReplyRoute>(
|
||||
`conversation_reply_route:${platform}:${normalizedConversationId}`,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
setConversationReplyRoute(
|
||||
platform: IMPlatform,
|
||||
conversationId: string,
|
||||
route: StoredConversationReplyRoute,
|
||||
): void {
|
||||
const normalizedConversationId = conversationId.trim();
|
||||
if (!normalizedConversationId) {
|
||||
return;
|
||||
}
|
||||
this.setConfigValue(`conversation_reply_route:${platform}:${normalizedConversationId}`, route);
|
||||
}
|
||||
|
||||
// ==================== Session Mapping Operations ====================
|
||||
|
||||
/**
|
||||
@@ -550,6 +581,25 @@ export class IMStore {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the IM mapping that owns a given cowork session ID.
|
||||
*/
|
||||
getSessionMappingByCoworkSessionId(coworkSessionId: string): IMSessionMapping | null {
|
||||
const result = this.db.exec(
|
||||
'SELECT im_conversation_id, platform, cowork_session_id, created_at, last_active_at FROM im_session_mappings WHERE cowork_session_id = ? LIMIT 1',
|
||||
[coworkSessionId]
|
||||
);
|
||||
if (!result[0]?.values[0]) return null;
|
||||
const row = result[0].values[0];
|
||||
return {
|
||||
imConversationId: row[0] as string,
|
||||
platform: row[1] as IMPlatform,
|
||||
coworkSessionId: row[2] as string,
|
||||
createdAt: row[3] as number,
|
||||
lastActiveAt: row[4] as number,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new session mapping
|
||||
*/
|
||||
|
||||
@@ -33,6 +33,7 @@ const BRIDGE_MAX_MESSAGES = 20;
|
||||
const BRIDGE_MAX_MESSAGE_CHARS = 1200;
|
||||
const GATEWAY_READY_TIMEOUT_MS = 15_000;
|
||||
const FINAL_HISTORY_SYNC_LIMIT = 50;
|
||||
const CHANNEL_SESSION_DISCOVERY_LIMIT = 200;
|
||||
|
||||
type GatewayEventFrame = {
|
||||
event: string;
|
||||
@@ -134,10 +135,22 @@ type PendingApprovalEntry = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
type ChannelHistorySyncEntry = {
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
};
|
||||
|
||||
const isSameChannelHistoryEntry = (
|
||||
left: ChannelHistorySyncEntry,
|
||||
right: ChannelHistorySyncEntry,
|
||||
): boolean => {
|
||||
return left.role === right.role && left.text === right.text;
|
||||
};
|
||||
|
||||
const truncate = (value: string, maxChars: number): string => {
|
||||
if (value.length <= maxChars) return value;
|
||||
return `${value.slice(0, maxChars)}...`;
|
||||
@@ -662,7 +675,7 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const params = { activeMinutes: 60, limit: 50 };
|
||||
const params = { activeMinutes: 60, limit: CHANNEL_SESSION_DISCOVERY_LIMIT };
|
||||
console.log('[ChannelSync] pollChannelSessions: calling sessions.list with', JSON.stringify(params));
|
||||
const result = await this.gatewayClient.request('sessions.list', params);
|
||||
const sessions = (result as Record<string, unknown>)?.sessions;
|
||||
@@ -2203,6 +2216,18 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel history prefetch/full-sync intentionally skips historical system entries.
|
||||
* Seed the raw gateway history cursor so those older reminders are not replayed
|
||||
* under the next assistant reply during final-history sync.
|
||||
*/
|
||||
private markGatewayHistoryWindowConsumed(sessionId: string, historyMessages: unknown[]): void {
|
||||
if (historyMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.gatewayHistoryCountBySession.set(sessionId, historyMessages.length);
|
||||
}
|
||||
|
||||
private async syncFinalAssistantWithHistory(sessionId: string, turn: ActiveTurn): Promise<void> {
|
||||
console.log('[Debug:syncFinal] start — sessionId:', sessionId, 'sessionKey:', turn.sessionKey);
|
||||
const client = this.gatewayClient;
|
||||
@@ -2355,25 +2380,12 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync user messages from gateway chat.history that haven't been added to the local store yet.
|
||||
* Used for channel-originated sessions (e.g. Telegram) where user messages arrive via the
|
||||
* gateway rather than the LobsterAI UI.
|
||||
*
|
||||
* Called at the start of a new turn (via prefetchChannelUserMessages) so that user messages
|
||||
* appear before the assistant's streaming response. Both chat and agent events are buffered
|
||||
* during prefetch, so the replay order matches direct cowork sessions.
|
||||
*
|
||||
* Uses position-based matching: compares history entries with local messages sequentially
|
||||
* to avoid false dedup of identical-content messages (e.g. two "ok" messages in a row).
|
||||
*/
|
||||
private syncChannelUserMessages(sessionId: string, historyMessages: unknown[], latestOnly = false, isDiscord = false, isQQ = false): void {
|
||||
console.log('[Debug:syncChannelUserMessages] sessionId:', sessionId, 'historyMessages:', historyMessages.length, 'latestOnly:', latestOnly, 'isQQ:', isQQ);
|
||||
const session = this.store.getSession(sessionId);
|
||||
|
||||
// Collect user + assistant messages from history in chronological order
|
||||
type MsgEntry = { role: 'user' | 'assistant'; text: string };
|
||||
const historyEntries: MsgEntry[] = [];
|
||||
private collectChannelHistoryEntries(
|
||||
historyMessages: unknown[],
|
||||
isDiscord: boolean,
|
||||
isQQ: boolean,
|
||||
): ChannelHistorySyncEntry[] {
|
||||
const historyEntries: ChannelHistorySyncEntry[] = [];
|
||||
for (const message of historyMessages) {
|
||||
if (!isRecord(message)) continue;
|
||||
const role = typeof message.role === 'string' ? message.role.trim().toLowerCase() : '';
|
||||
@@ -2385,6 +2397,127 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
|
||||
historyEntries.push({ role: role as 'user' | 'assistant', text });
|
||||
}
|
||||
}
|
||||
return historyEntries;
|
||||
}
|
||||
|
||||
private collectLocalChannelEntries(sessionId: string): ChannelHistorySyncEntry[] {
|
||||
const session = this.store.getSession(sessionId);
|
||||
if (!session) return [];
|
||||
|
||||
const localEntries: ChannelHistorySyncEntry[] = [];
|
||||
for (const msg of session.messages) {
|
||||
if (msg.type !== 'user' && msg.type !== 'assistant') continue;
|
||||
const text = msg.content.trim();
|
||||
if (!text) continue;
|
||||
localEntries.push({ role: msg.type, text });
|
||||
}
|
||||
return localEntries;
|
||||
}
|
||||
|
||||
private computeChannelHistoryFirstNewIndex(
|
||||
localEntries: ChannelHistorySyncEntry[],
|
||||
historyEntries: ChannelHistorySyncEntry[],
|
||||
cursor: number,
|
||||
): { firstNewIdx: number; strategy: string } {
|
||||
if (localEntries.length === 0) {
|
||||
return { firstNewIdx: 0, strategy: 'empty-local' };
|
||||
}
|
||||
|
||||
// `chat.history` is byte-bounded in OpenClaw, so the returned window can slide
|
||||
// long before it reaches our requested count. Match the local tail against the
|
||||
// current history prefix to find the continuation point without trusting length.
|
||||
const maxOverlap = Math.min(localEntries.length, historyEntries.length);
|
||||
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
||||
let matched = true;
|
||||
for (let idx = 0; idx < overlap; idx += 1) {
|
||||
const localEntry = localEntries[localEntries.length - overlap + idx];
|
||||
const historyEntry = historyEntries[idx];
|
||||
if (!isSameChannelHistoryEntry(localEntry, historyEntry)) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) {
|
||||
return { firstNewIdx: overlap, strategy: 'tail-overlap' };
|
||||
}
|
||||
}
|
||||
|
||||
let lastLocalUserIdx = -1;
|
||||
for (let idx = localEntries.length - 1; idx >= 0; idx -= 1) {
|
||||
if (localEntries[idx].role === 'user') {
|
||||
lastLocalUserIdx = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastLocalUserIdx >= 0) {
|
||||
const lastLocalUser = localEntries[lastLocalUserIdx];
|
||||
let prevLocalUserText: string | undefined;
|
||||
for (let idx = lastLocalUserIdx - 1; idx >= 0; idx -= 1) {
|
||||
if (localEntries[idx].role === 'user') {
|
||||
prevLocalUserText = localEntries[idx].text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (let idx = historyEntries.length - 1; idx >= 0; idx -= 1) {
|
||||
if (historyEntries[idx].role !== 'user' || historyEntries[idx].text !== lastLocalUser.text) {
|
||||
continue;
|
||||
}
|
||||
if (prevLocalUserText !== undefined && idx > 0) {
|
||||
let prevHistUserText: string | undefined;
|
||||
for (let histIdx = idx - 1; histIdx >= 0; histIdx -= 1) {
|
||||
if (historyEntries[histIdx].role === 'user') {
|
||||
prevHistUserText = historyEntries[histIdx].text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (prevHistUserText !== prevLocalUserText) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return { firstNewIdx: idx + 1, strategy: 'last-user-anchor' };
|
||||
}
|
||||
}
|
||||
|
||||
let localIdx = 0;
|
||||
let forwardFirstNewIdx = 0;
|
||||
for (let idx = 0; idx < historyEntries.length; idx += 1) {
|
||||
if (localIdx < localEntries.length && isSameChannelHistoryEntry(historyEntries[idx], localEntries[localIdx])) {
|
||||
localIdx += 1;
|
||||
forwardFirstNewIdx = idx + 1;
|
||||
}
|
||||
}
|
||||
if (forwardFirstNewIdx > 0) {
|
||||
return { firstNewIdx: forwardFirstNewIdx, strategy: 'forward-match' };
|
||||
}
|
||||
|
||||
if (historyEntries.length < cursor) {
|
||||
return { firstNewIdx: 0, strategy: 'history-rewrite' };
|
||||
}
|
||||
|
||||
return {
|
||||
firstNewIdx: Math.min(cursor, historyEntries.length),
|
||||
strategy: 'cursor-fallback',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync user messages from gateway chat.history that haven't been added to the local store yet.
|
||||
* Used for channel-originated sessions (e.g. Telegram) where user messages arrive via the
|
||||
* gateway rather than the LobsterAI UI.
|
||||
*
|
||||
* Called at the start of a new turn (via prefetchChannelUserMessages) so that user messages
|
||||
* appear before the assistant's streaming response. Both chat and agent events are buffered
|
||||
* during prefetch, so the replay order matches direct cowork sessions.
|
||||
*
|
||||
* Reconciles against the local tail instead of trusting history length/cursor alone,
|
||||
* because OpenClaw's `chat.history` window can slide due to byte limits well before
|
||||
* the requested message count is reached.
|
||||
*/
|
||||
private syncChannelUserMessages(sessionId: string, historyMessages: unknown[], latestOnly = false, isDiscord = false, isQQ = false): void {
|
||||
console.log('[Debug:syncChannelUserMessages] sessionId:', sessionId, 'historyMessages:', historyMessages.length, 'latestOnly:', latestOnly, 'isQQ:', isQQ);
|
||||
const historyEntries = this.collectChannelHistoryEntries(historyMessages, isDiscord, isQQ);
|
||||
|
||||
const cursor = this.channelSyncCursor.get(sessionId) ?? 0;
|
||||
console.log('[Debug:syncChannelUserMessages] cursor:', cursor, 'history entries:', historyEntries.length);
|
||||
@@ -2394,7 +2527,7 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
|
||||
// Advance cursor to end so subsequent syncs don't replay old history.
|
||||
if (latestOnly) {
|
||||
if (historyEntries.length > 0) {
|
||||
const lastUser = [...historyEntries].reverse().find(e => e.role === 'user');
|
||||
const lastUser = [...historyEntries].reverse().find((entry) => entry.role === 'user');
|
||||
if (lastUser) {
|
||||
const userMessage = this.store.addMessage(sessionId, {
|
||||
type: 'user',
|
||||
@@ -2409,124 +2542,18 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine firstNewIdx: where in historyEntries new (unsynced) messages start.
|
||||
// Use RAW message count (historyMessages.length) to detect sliding window, NOT the
|
||||
// filtered historyEntries count or cursor. When the gateway returns FINAL_HISTORY_SYNC_LIMIT
|
||||
// raw messages, the window is at capacity and sliding — even if filtered entries are fewer
|
||||
// due to toolCall/toolResult entries being stripped (e.g. raw=50, filtered=48, cursor=48).
|
||||
let firstNewIdx: number;
|
||||
if (historyMessages.length >= FINAL_HISTORY_SYNC_LIMIT) {
|
||||
// Sliding window: gateway returned a full window, position-based cursor is unreliable.
|
||||
// Find the continuation point by matching the last local message in history.
|
||||
console.log('[Debug:syncChannelUserMessages] history at capacity (raw:', historyMessages.length, ', filtered:', historyEntries.length, ', cursor:', cursor, '), using content matching');
|
||||
const localEntries: MsgEntry[] = [];
|
||||
if (session) {
|
||||
for (const msg of session.messages) {
|
||||
if (msg.type === 'user' || msg.type === 'assistant') {
|
||||
localEntries.push({ role: msg.type, text: msg.content.trim() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (localEntries.length > 0) {
|
||||
const lastLocal = localEntries[localEntries.length - 1];
|
||||
let matchPos = -1;
|
||||
for (let i = historyEntries.length - 1; i >= 0; i--) {
|
||||
if (historyEntries[i].role === lastLocal.role
|
||||
&& historyEntries[i].text === lastLocal.text) {
|
||||
// Double-check: verify the preceding entry also matches to avoid
|
||||
// false positives from repeated identical messages (e.g. "再来一个").
|
||||
if (localEntries.length >= 2 && i > 0) {
|
||||
const secondLastLocal = localEntries[localEntries.length - 2];
|
||||
if (historyEntries[i - 1].role === secondLastLocal.role
|
||||
&& historyEntries[i - 1].text === secondLastLocal.text) {
|
||||
matchPos = i;
|
||||
break;
|
||||
}
|
||||
// Preceding entry didn't match — might be a duplicate, keep searching.
|
||||
continue;
|
||||
}
|
||||
matchPos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if the last local entry is an assistant message and matching failed,
|
||||
// the stored text may be a segment (from resolveAssistantSegmentText) that differs
|
||||
// from the full text in gateway history. Retry with the last USER message instead,
|
||||
// which is always stored verbatim and matches gateway history exactly.
|
||||
if (matchPos < 0 && lastLocal.role === 'assistant') {
|
||||
let lastLocalUserIdx = -1;
|
||||
for (let j = localEntries.length - 1; j >= 0; j--) {
|
||||
if (localEntries[j].role === 'user') {
|
||||
lastLocalUserIdx = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastLocalUserIdx >= 0) {
|
||||
const lastLocalUser = localEntries[lastLocalUserIdx];
|
||||
// Find preceding user message in local for double-verification
|
||||
let prevLocalUserText: string | undefined;
|
||||
for (let j = lastLocalUserIdx - 1; j >= 0; j--) {
|
||||
if (localEntries[j].role === 'user') {
|
||||
prevLocalUserText = localEntries[j].text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
console.log('[Debug:syncChannelUserMessages] assistant match failed, retrying with last user entry');
|
||||
for (let i = historyEntries.length - 1; i >= 0; i--) {
|
||||
if (historyEntries[i].role === 'user'
|
||||
&& historyEntries[i].text === lastLocalUser.text) {
|
||||
// Double-check: verify the preceding user message also matches
|
||||
if (prevLocalUserText !== undefined && i > 0) {
|
||||
let prevHistUserText: string | undefined;
|
||||
for (let k = i - 1; k >= 0; k--) {
|
||||
if (historyEntries[k].role === 'user') {
|
||||
prevHistUserText = historyEntries[k].text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (prevHistUserText !== prevLocalUserText) {
|
||||
continue; // Double-check failed, keep searching
|
||||
}
|
||||
}
|
||||
matchPos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
firstNewIdx = matchPos >= 0 ? matchPos + 1 : historyEntries.length;
|
||||
console.log('[Debug:syncChannelUserMessages] content match result: matchPos:', matchPos, 'firstNewIdx:', firstNewIdx);
|
||||
} else {
|
||||
firstNewIdx = 0; // No local messages, sync everything
|
||||
}
|
||||
} else if (historyEntries.length < cursor) {
|
||||
// Safety: gateway returned fewer entries than cursor (session truncated/rebuilt).
|
||||
// Only reached when raw history < FINAL_HISTORY_SYNC_LIMIT (not a sliding window).
|
||||
console.warn('[Debug:syncChannelUserMessages] history shrank (cursor:', cursor, 'entries:', historyEntries.length, '), falling back to text matching');
|
||||
const localEntries: MsgEntry[] = [];
|
||||
if (session) {
|
||||
for (const msg of session.messages) {
|
||||
if (msg.type === 'user' || msg.type === 'assistant') {
|
||||
localEntries.push({ role: msg.type, text: msg.content.trim() });
|
||||
}
|
||||
}
|
||||
}
|
||||
let localIdx = 0;
|
||||
firstNewIdx = 0;
|
||||
for (let i = 0; i < historyEntries.length; i++) {
|
||||
if (localIdx < localEntries.length
|
||||
&& historyEntries[i].role === localEntries[localIdx].role
|
||||
&& historyEntries[i].text === localEntries[localIdx].text) {
|
||||
localIdx++;
|
||||
firstNewIdx = i + 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
firstNewIdx = cursor;
|
||||
}
|
||||
const localEntries = this.collectLocalChannelEntries(sessionId);
|
||||
const { firstNewIdx, strategy } = this.computeChannelHistoryFirstNewIndex(localEntries, historyEntries, cursor);
|
||||
console.log(
|
||||
'[Debug:syncChannelUserMessages] continuation strategy:',
|
||||
strategy,
|
||||
'firstNewIdx:',
|
||||
firstNewIdx,
|
||||
'local entries:',
|
||||
localEntries.length,
|
||||
'history entries:',
|
||||
historyEntries.length,
|
||||
);
|
||||
|
||||
// Append messages from firstNewIdx onwards.
|
||||
// Only sync user messages here — assistant messages are already added by the
|
||||
@@ -2578,6 +2605,7 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
|
||||
console.log('[ChannelSync] syncFullChannelHistory: no messages in history');
|
||||
return;
|
||||
}
|
||||
this.markGatewayHistoryWindowConsumed(sessionId, history.messages);
|
||||
|
||||
const session = this.store.getSession(sessionId);
|
||||
// Build ordered list of existing local messages for position-based matching
|
||||
@@ -2927,6 +2955,7 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
|
||||
console.log('[Debug:prefetch] chat.history returned', msgCount, 'messages (attempt', attempt, ')');
|
||||
|
||||
if (Array.isArray(history?.messages) && history.messages.length > 0) {
|
||||
this.markGatewayHistoryWindowConsumed(sessionId, history.messages);
|
||||
const latestOnly = this.reCreatedChannelSessionIds.has(sessionId);
|
||||
const beforeCount = this.getUserMessageCount(sessionId);
|
||||
this.syncChannelUserMessages(sessionId, history.messages, latestOnly, sessionKey.includes(':discord:'), sessionKey.includes(':qqbot:'));
|
||||
@@ -3061,6 +3090,36 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
|
||||
return this.gatewayClient;
|
||||
}
|
||||
|
||||
getSessionKeysForSession(sessionId: string): string[] {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keys: string[] = [];
|
||||
for (const [key, mappedSessionId] of this.sessionIdBySessionKey.entries()) {
|
||||
if (mappedSessionId === normalizedSessionId) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const managedKey = this.toSessionKey(normalizedSessionId);
|
||||
if (!keys.includes(managedKey)) {
|
||||
keys.push(managedKey);
|
||||
}
|
||||
|
||||
keys.sort((left, right) => {
|
||||
const leftManaged = isManagedSessionKey(left);
|
||||
const rightManaged = isManagedSessionKey(right);
|
||||
if (leftManaged !== rightManaged) {
|
||||
return leftManaged ? 1 : -1;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the gateway client is connected and ready.
|
||||
* Resolves when the WebSocket connection is established and authenticated.
|
||||
|
||||
@@ -43,6 +43,8 @@ const MANAGED_OWNER_ALLOW_FROM = [
|
||||
'gateway-client',
|
||||
];
|
||||
|
||||
const MANAGED_TOOL_DENY = ['web_search'] as const;
|
||||
|
||||
const MANAGED_SKILL_ENTRY_OVERRIDES: Record<string, { enabled: boolean }> = {
|
||||
// QQ plugin ships a legacy reminder skill that steers the model toward a
|
||||
// channel-specific cron wrapper/subagent flow. Hide that path so native IM
|
||||
@@ -62,6 +64,19 @@ const DISABLED_MANAGED_SKILL_NAMES = Object.entries(MANAGED_SKILL_ENTRY_OVERRIDE
|
||||
.filter(([, value]) => value.enabled === false)
|
||||
.map(([name]) => name);
|
||||
|
||||
const MANAGED_WEB_SEARCH_POLICY_PROMPT = [
|
||||
'## Web Search',
|
||||
'',
|
||||
'Built-in `web_search` is disabled in this workspace. Do not ask for or rely on the Brave Search API.',
|
||||
'',
|
||||
'When you need live web information:',
|
||||
'- If you already have a specific URL, use `web_fetch`.',
|
||||
'- If you need search discovery, dynamic pages, or interactive browsing, use the built-in `browser` tool.',
|
||||
'- Only use the LobsterAI `web-search` skill when local command execution is available. Native channel sessions may deny `exec`, so prefer `browser` or `web_fetch` there.',
|
||||
'',
|
||||
'Do not claim you searched the web unless you actually used `browser`, `web_fetch`, or the LobsterAI `web-search` skill.',
|
||||
].join('\n');
|
||||
|
||||
const sessionSnapshotContainsDisabledManagedSkill = (entry: Record<string, unknown>): boolean => {
|
||||
const skillsSnapshot = entry.skillsSnapshot;
|
||||
if (!skillsSnapshot || typeof skillsSnapshot !== 'object') {
|
||||
@@ -401,6 +416,17 @@ export class OpenClawConfigSync {
|
||||
commands: {
|
||||
ownerAllowFrom: MANAGED_OWNER_ALLOW_FROM,
|
||||
},
|
||||
tools: {
|
||||
deny: [...MANAGED_TOOL_DENY],
|
||||
web: {
|
||||
search: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
browser: {
|
||||
enabled: true,
|
||||
},
|
||||
skills: {
|
||||
entries: MANAGED_SKILL_ENTRY_OVERRIDES,
|
||||
},
|
||||
@@ -804,6 +830,8 @@ export class OpenClawConfigSync {
|
||||
sections.push(skillsPrompt);
|
||||
}
|
||||
|
||||
sections.push(MANAGED_WEB_SEARCH_POLICY_PROMPT);
|
||||
|
||||
// Keep scheduled-task policy after skills so native channel sessions
|
||||
// treat it as the final app-managed override for reminder handling.
|
||||
const scheduledTaskPrompt = buildScheduledTaskEnginePrompt('openclaw').replaceAll(MARKER, '');
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
parseScheduledReminderPrompt,
|
||||
parseSimpleScheduledReminderText,
|
||||
} from '../../common/scheduledReminderText';
|
||||
|
||||
type GatewayHistoryRole = 'user' | 'assistant' | 'system';
|
||||
|
||||
export interface GatewayHistoryEntry {
|
||||
@@ -5,11 +10,6 @@ export interface GatewayHistoryEntry {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const SCHEDULED_REMINDER_PREFIX = 'A scheduled reminder has been triggered. The reminder content is:';
|
||||
const SCHEDULED_REMINDER_INTERNAL_INSTRUCTION = 'Handle this reminder internally. Do not relay it to the user unless explicitly requested.';
|
||||
const SCHEDULED_REMINDER_RELAY_INSTRUCTION = 'Please relay this reminder to the user in a helpful and friendly way.';
|
||||
const CURRENT_TIME_PREFIX = 'Current time:';
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
};
|
||||
@@ -44,62 +44,13 @@ export const extractGatewayMessageText = (message: unknown): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
type ScheduledReminderPrompt = {
|
||||
reminderText: string;
|
||||
currentTime?: string;
|
||||
};
|
||||
|
||||
const parseScheduledReminderPrompt = (text: string): ScheduledReminderPrompt | null => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed.startsWith(SCHEDULED_REMINDER_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let remainder = trimmed.slice(SCHEDULED_REMINDER_PREFIX.length).trim();
|
||||
let currentTime: string | undefined;
|
||||
const currentTimeIndex = remainder.lastIndexOf(CURRENT_TIME_PREFIX);
|
||||
if (currentTimeIndex >= 0) {
|
||||
currentTime = remainder.slice(currentTimeIndex + CURRENT_TIME_PREFIX.length).trim() || undefined;
|
||||
remainder = remainder.slice(0, currentTimeIndex).trim();
|
||||
}
|
||||
|
||||
if (remainder.endsWith(SCHEDULED_REMINDER_INTERNAL_INSTRUCTION)) {
|
||||
remainder = remainder.slice(0, -SCHEDULED_REMINDER_INTERNAL_INSTRUCTION.length).trim();
|
||||
} else if (remainder.endsWith(SCHEDULED_REMINDER_RELAY_INSTRUCTION)) {
|
||||
remainder = remainder.slice(0, -SCHEDULED_REMINDER_RELAY_INSTRUCTION.length).trim();
|
||||
}
|
||||
|
||||
if (!remainder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
reminderText: remainder,
|
||||
...(currentTime ? { currentTime } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildScheduledReminderSystemMessage = (text: string): string | null => {
|
||||
const parsed = parseScheduledReminderPrompt(text);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
return parseSimpleScheduledReminderText(text)?.reminderText ?? null;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`System: ${parsed.currentTime ? `[${parsed.currentTime}] ` : ''}${parsed.reminderText}`,
|
||||
'',
|
||||
SCHEDULED_REMINDER_PREFIX,
|
||||
'',
|
||||
parsed.reminderText,
|
||||
'',
|
||||
SCHEDULED_REMINDER_RELAY_INSTRUCTION,
|
||||
];
|
||||
|
||||
if (parsed.currentTime) {
|
||||
lines.push(`${CURRENT_TIME_PREFIX} ${parsed.currentTime}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
return parsed.reminderText;
|
||||
};
|
||||
|
||||
export const extractGatewayHistoryEntry = (message: unknown): GatewayHistoryEntry | null => {
|
||||
|
||||
@@ -7,6 +7,103 @@ const OWNER_ONLY_FALLBACK_PATTERN =
|
||||
/(const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set\(\[\s*\n\s*"whatsapp_login",\s*\n\s*)"cron",\s*\n(\s*"gateway"\s*\n\s*\]\);)/;
|
||||
const WECOM_EXEC_DENY_PATTERN =
|
||||
/const TOOL_DENY_BY_MESSAGE_PROVIDER = \{ voice: \["tts"\](?:,\s*wecom: \["exec", "process"\])? \};/;
|
||||
const CRON_DELIVERY_INFERENCE_OLD = `function inferDeliveryFromSessionKey(agentSessionKey) {
|
||||
\tconst rawSessionKey = agentSessionKey?.trim();
|
||||
\tif (!rawSessionKey) return null;
|
||||
\tconst parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));
|
||||
\tif (!parsed || !parsed.rest) return null;
|
||||
\tconst parts = parsed.rest.split(":").filter(Boolean);
|
||||
\tif (parts.length === 0) return null;
|
||||
\tconst head = parts[0]?.trim().toLowerCase();
|
||||
\tif (!head || head === "main" || head === "subagent" || head === "acp") return null;
|
||||
\tconst markerIndex = parts.findIndex((part) => part === "direct" || part === "dm" || part === "group" || part === "channel");
|
||||
\tif (markerIndex === -1) return null;
|
||||
\tconst peerId = parts.slice(markerIndex + 1).join(":").trim();
|
||||
\tif (!peerId) return null;
|
||||
\tlet channel;
|
||||
\tif (markerIndex >= 1) channel = parts[0]?.trim().toLowerCase();
|
||||
\tconst delivery = {
|
||||
\t\tmode: "announce",
|
||||
\t\tto: peerId
|
||||
\t};
|
||||
\tif (channel) delivery.channel = channel;
|
||||
\treturn delivery;
|
||||
}`;
|
||||
const CRON_DELIVERY_INFERENCE_NEW = `function inferDeliveryFromSessionKey(agentSessionKey) {
|
||||
\tconst rawSessionKey = agentSessionKey?.trim();
|
||||
\tif (!rawSessionKey) return null;
|
||||
\tconst { deliveryContext, threadId } = extractDeliveryInfo(rawSessionKey);
|
||||
\tconst persistedDelivery = normalizeDeliveryContext(deliveryContext);
|
||||
\tif (persistedDelivery?.channel && persistedDelivery?.to) {
|
||||
\t\tconst delivery = {
|
||||
\t\t\tmode: "announce",
|
||||
\t\t\tchannel: persistedDelivery.channel,
|
||||
\t\t\tto: persistedDelivery.to
|
||||
\t\t};
|
||||
\t\tif (persistedDelivery.accountId) delivery.accountId = persistedDelivery.accountId;
|
||||
\t\tif (persistedDelivery.threadId != null) delivery.threadId = persistedDelivery.threadId;
|
||||
\t\telse if (threadId != null) delivery.threadId = threadId;
|
||||
\t\treturn delivery;
|
||||
\t}
|
||||
\tconst parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));
|
||||
\tif (!parsed || !parsed.rest) return null;
|
||||
\tconst parts = parsed.rest.split(":").filter(Boolean);
|
||||
\tif (parts.length === 0) return null;
|
||||
\tconst head = parts[0]?.trim().toLowerCase();
|
||||
\tif (!head || head === "main" || head === "subagent" || head === "acp") return null;
|
||||
\tif (head === "dingtalk-connector") {
|
||||
\t\tconst accountId = parts[1]?.trim();
|
||||
\t\tconst senderId = parts[2]?.trim();
|
||||
\t\tif (accountId && senderId) {
|
||||
\t\t\tconst delivery = {
|
||||
\t\t\t\tmode: "announce",
|
||||
\t\t\t\tchannel: "dingtalk-connector",
|
||||
\t\t\t\tto: \`user:\${senderId}\`,
|
||||
\t\t\t\taccountId
|
||||
\t\t\t};
|
||||
\t\t\tif (threadId != null) delivery.threadId = threadId;
|
||||
\t\t\treturn delivery;
|
||||
\t\t}
|
||||
\t}
|
||||
\tconst markerIndex = parts.findIndex((part) => part === "direct" || part === "dm" || part === "group" || part === "channel");
|
||||
\tif (markerIndex === -1) return null;
|
||||
\tconst peerId = parts.slice(markerIndex + 1).join(":").trim();
|
||||
\tif (!peerId) return null;
|
||||
\tlet channel;
|
||||
\tif (markerIndex >= 1) channel = parts[0]?.trim().toLowerCase();
|
||||
\tconst delivery = {
|
||||
\t\tmode: "announce",
|
||||
\t\tto: peerId
|
||||
\t};
|
||||
\tif (channel) delivery.channel = channel;
|
||||
\tif (threadId != null) delivery.threadId = threadId;
|
||||
\treturn delivery;
|
||||
}`;
|
||||
const CRON_REMINDER_PROMPT_OLD = [
|
||||
'function buildCronEventPrompt(pendingEvents, opts) {',
|
||||
'\tconst deliverToUser = opts?.deliverToUser ?? true;',
|
||||
'\tconst eventText = pendingEvents.join("\\n").trim();',
|
||||
'\tif (!eventText) {',
|
||||
'\t\tif (!deliverToUser) return "A scheduled cron event was triggered, but no event content was found. Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up.";',
|
||||
'\t\treturn "A scheduled cron event was triggered, but no event content was found. Reply HEARTBEAT_OK.";',
|
||||
'\t}',
|
||||
'\tif (!deliverToUser) return "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nHandle this reminder internally. Do not relay it to the user unless explicitly requested.";',
|
||||
'\treturn "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nPlease relay this reminder to the user in a helpful and friendly way.";',
|
||||
'}',
|
||||
].join('\n');
|
||||
const CRON_REMINDER_PROMPT_NEW = [
|
||||
'function buildCronEventPrompt(pendingEvents, opts) {',
|
||||
'\tconst deliverToUser = opts?.deliverToUser ?? true;',
|
||||
'\tconst eventText = pendingEvents.join("\\n").trim();',
|
||||
'\tif (!eventText) {',
|
||||
'\t\tif (!deliverToUser) return "A scheduled cron event was triggered, but no event content was found. Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up.";',
|
||||
'\t\treturn "A scheduled cron event was triggered, but no event content was found. Reply HEARTBEAT_OK.";',
|
||||
'\t}',
|
||||
'\treturn eventText;',
|
||||
'}',
|
||||
].join('\n');
|
||||
const CRON_REMINDER_CURRENT_TIME_BODY_OLD = 'Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),';
|
||||
const CRON_REMINDER_CURRENT_TIME_BODY_NEW = 'Body: hasCronEvents ? prompt : appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),';
|
||||
|
||||
const walkJsFiles = (dirPath: string, files: string[]): void => {
|
||||
let entries: fs.Dirent[];
|
||||
@@ -69,6 +166,57 @@ export const patchWecomMessageProviderExecDeny = (
|
||||
};
|
||||
};
|
||||
|
||||
export const patchCronSessionDeliveryInference = (
|
||||
source: string,
|
||||
): { changed: boolean; content: string } => {
|
||||
if (
|
||||
!source.includes('function inferDeliveryFromSessionKey(agentSessionKey) {') ||
|
||||
!source.includes('const parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));')
|
||||
) {
|
||||
return { changed: false, content: source };
|
||||
}
|
||||
const next = source.replace(CRON_DELIVERY_INFERENCE_OLD, CRON_DELIVERY_INFERENCE_NEW);
|
||||
return {
|
||||
changed: next !== source,
|
||||
content: next,
|
||||
};
|
||||
};
|
||||
|
||||
export const patchCronReminderPromptEnvelope = (
|
||||
source: string,
|
||||
): { changed: boolean; content: string } => {
|
||||
if (
|
||||
!source.includes('function buildCronEventPrompt(') ||
|
||||
!source.includes('A scheduled reminder has been triggered. The reminder content is:')
|
||||
) {
|
||||
return { changed: false, content: source };
|
||||
}
|
||||
const next = source.replace(CRON_REMINDER_PROMPT_OLD, CRON_REMINDER_PROMPT_NEW);
|
||||
return {
|
||||
changed: next !== source,
|
||||
content: next,
|
||||
};
|
||||
};
|
||||
|
||||
export const patchCronReminderCurrentTimeSuffix = (
|
||||
source: string,
|
||||
): { changed: boolean; content: string } => {
|
||||
if (
|
||||
!source.includes(CRON_REMINDER_CURRENT_TIME_BODY_OLD) ||
|
||||
!source.includes('hasCronEvents')
|
||||
) {
|
||||
return { changed: false, content: source };
|
||||
}
|
||||
const next = source.replace(
|
||||
CRON_REMINDER_CURRENT_TIME_BODY_OLD,
|
||||
CRON_REMINDER_CURRENT_TIME_BODY_NEW,
|
||||
);
|
||||
return {
|
||||
changed: next !== source,
|
||||
content: next,
|
||||
};
|
||||
};
|
||||
|
||||
export const applyBundledOpenClawRuntimeHotfixes = (
|
||||
runtimeRoot: string,
|
||||
): { changed: boolean; patchedFiles: string[]; errors: string[] } => {
|
||||
@@ -91,10 +239,22 @@ export const applyBundledOpenClawRuntimeHotfixes = (
|
||||
const cronOwnerPatch = patchCronToolOwnerOnly(source);
|
||||
const fallbackPatch = patchCronOwnerFallback(cronOwnerPatch.content);
|
||||
const wecomExecPatch = patchWecomMessageProviderExecDeny(fallbackPatch.content);
|
||||
if (!cronOwnerPatch.changed && !fallbackPatch.changed && !wecomExecPatch.changed) continue;
|
||||
const cronDeliveryPatch = patchCronSessionDeliveryInference(wecomExecPatch.content);
|
||||
const cronReminderPromptPatch = patchCronReminderPromptEnvelope(cronDeliveryPatch.content);
|
||||
const cronReminderTimePatch = patchCronReminderCurrentTimeSuffix(cronReminderPromptPatch.content);
|
||||
if (
|
||||
!cronOwnerPatch.changed &&
|
||||
!fallbackPatch.changed &&
|
||||
!wecomExecPatch.changed &&
|
||||
!cronDeliveryPatch.changed &&
|
||||
!cronReminderPromptPatch.changed &&
|
||||
!cronReminderTimePatch.changed
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, wecomExecPatch.content, 'utf8');
|
||||
fs.writeFileSync(filePath, cronReminderTimePatch.content, 'utf8');
|
||||
patchedFiles.push(filePath);
|
||||
} catch (error) {
|
||||
errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
|
||||
+19
-1
@@ -1117,7 +1117,25 @@ const getIMGatewayManager = () => {
|
||||
await openClawRuntimeAdapter.connectGatewayIfNeeded();
|
||||
}
|
||||
},
|
||||
createScheduledTask: async ({ sessionId, request }) => {
|
||||
getOpenClawGatewayClient: () => openClawRuntimeAdapter?.getGatewayClient() ?? null,
|
||||
ensureOpenClawGatewayReady: async () => {
|
||||
if (!openClawRuntimeAdapter) {
|
||||
throw new Error('OpenClaw runtime adapter not initialized.');
|
||||
}
|
||||
await openClawRuntimeAdapter.ensureReady();
|
||||
await openClawRuntimeAdapter.connectGatewayIfNeeded();
|
||||
},
|
||||
getOpenClawSessionKeysForCoworkSession: (sessionId: string) => {
|
||||
return openClawRuntimeAdapter?.getSessionKeysForSession(sessionId) ?? [];
|
||||
},
|
||||
createScheduledTask: async ({ sessionId, message, request }) => {
|
||||
if (message.platform === 'dingtalk') {
|
||||
await getIMGatewayManager().primeConversationReplyRoute(
|
||||
message.platform,
|
||||
message.conversationId,
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
const task = await getCronJobService().addJob({
|
||||
name: request.taskName,
|
||||
description: '',
|
||||
|
||||
@@ -24,6 +24,7 @@ import PencilSquareIcon from '../icons/PencilSquareIcon';
|
||||
import TrashIcon from '../icons/TrashIcon';
|
||||
import WindowTitleBar from '../window/WindowTitleBar';
|
||||
import { getCompactFolderName } from '../../utils/path';
|
||||
import { getScheduledReminderDisplayText } from '../../../common/scheduledReminderText';
|
||||
|
||||
interface CoworkSessionDetailProps {
|
||||
onManageSkills?: () => void;
|
||||
@@ -1136,7 +1137,8 @@ export const AssistantTurnBlock: React.FC<{
|
||||
const rawContent = hasText(message.content)
|
||||
? message.content
|
||||
: (typeof message.metadata?.error === 'string' ? message.metadata.error : '');
|
||||
const content = mapDisplayText ? mapDisplayText(rawContent) : rawContent;
|
||||
const normalizedContent = getScheduledReminderDisplayText(rawContent) ?? rawContent;
|
||||
const content = mapDisplayText ? mapDisplayText(normalizedContent) : normalizedContent;
|
||||
if (!content.trim()) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -35,6 +35,7 @@ class CoworkService {
|
||||
private openClawStatus: OpenClawEngineStatus | null = null;
|
||||
private openClawStatusListeners = new Set<(status: OpenClawEngineStatus) => void>();
|
||||
private openClawEngineListenerAttached = false;
|
||||
private latestLoadSessionsRequestId = 0;
|
||||
private latestLoadSessionRequestId = 0;
|
||||
|
||||
async init(): Promise<void> {
|
||||
@@ -165,8 +166,14 @@ class CoworkService {
|
||||
}
|
||||
|
||||
async loadSessions(): Promise<void> {
|
||||
const requestId = ++this.latestLoadSessionsRequestId;
|
||||
const result = await window.electron?.cowork?.listSessions();
|
||||
if (result?.success && result.sessions) {
|
||||
// High-frequency IM traffic can trigger overlapping list refreshes.
|
||||
// Ignore stale responses so an older snapshot does not hide newer sessions.
|
||||
if (requestId !== this.latestLoadSessionsRequestId) {
|
||||
return;
|
||||
}
|
||||
store.dispatch(setSessions(result.sessions));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ class FakeIMStore {
|
||||
)) || null;
|
||||
}
|
||||
|
||||
getSessionMappingByCoworkSessionId(coworkSessionId) {
|
||||
return this.mappings.find((entry) => entry.coworkSessionId === coworkSessionId) || null;
|
||||
}
|
||||
|
||||
createSessionMapping(imConversationId, platform, coworkSessionId) {
|
||||
const mapping = {
|
||||
imConversationId,
|
||||
@@ -245,7 +249,7 @@ test('async reminder turns on IM-created sessions relay back to the original IM
|
||||
runtime.emit('message', session.id, {
|
||||
id: 'system-1',
|
||||
type: 'system',
|
||||
content: 'System: [Sunday, March 15th, 2026 — 4:30 PM] ⏰ 提醒:喝水',
|
||||
content: '⏰ 提醒:喝水',
|
||||
timestamp: Date.now(),
|
||||
metadata: {},
|
||||
});
|
||||
@@ -271,6 +275,54 @@ test('async reminder turns on IM-created sessions relay back to the original IM
|
||||
handler.destroy();
|
||||
});
|
||||
|
||||
test('async reminder turns on channel-synced sessions are tracked lazily and relay back', async () => {
|
||||
const runtime = new FakeRuntime();
|
||||
const coworkStore = new FakeCoworkStore();
|
||||
const imStore = new FakeIMStore();
|
||||
const relayedReplies = [];
|
||||
|
||||
const session = coworkStore.createSession('IM-dingtalk', process.cwd(), '', 'auto');
|
||||
imStore.createSessionMapping('default:user-42', 'dingtalk', session.id);
|
||||
|
||||
const handler = new IMCoworkHandler({
|
||||
coworkRuntime: runtime,
|
||||
coworkStore,
|
||||
imStore,
|
||||
sendAsyncReply: async (platform, conversationId, text) => {
|
||||
relayedReplies.push({ platform, conversationId, text });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
runtime.emit('message', session.id, {
|
||||
id: 'system-1',
|
||||
type: 'system',
|
||||
content: '⏰ 提醒:开会',
|
||||
timestamp: Date.now(),
|
||||
metadata: {},
|
||||
});
|
||||
runtime.emit('message', session.id, {
|
||||
id: 'assistant-1',
|
||||
type: 'assistant',
|
||||
content: '时间到了,记得开会。',
|
||||
timestamp: Date.now(),
|
||||
metadata: {},
|
||||
});
|
||||
runtime.emit('complete', session.id, null);
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(relayedReplies, [
|
||||
{
|
||||
platform: 'dingtalk',
|
||||
conversationId: 'default:user-42',
|
||||
text: '时间到了,记得开会。',
|
||||
},
|
||||
]);
|
||||
|
||||
handler.destroy();
|
||||
});
|
||||
|
||||
test('falls back to normal agent execution when detector does not recognize a scheduled task', async () => {
|
||||
const runtime = new FakeRuntime();
|
||||
const coworkStore = new FakeCoworkStore();
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
buildDingTalkSessionKeyCandidates,
|
||||
buildDingTalkSendParamsFromRoute,
|
||||
extractOpenClawDeliveryRoute,
|
||||
resolveOpenClawDeliveryRouteForSessionKeys,
|
||||
resolveManagedSessionDeliveryRoute,
|
||||
} = require('../dist-electron/main/im/imDeliveryRoute.js');
|
||||
|
||||
test('managed session delivery route prefers deliveryContext over legacy last route fields', () => {
|
||||
const resolved = resolveManagedSessionDeliveryRoute('session-1', [
|
||||
{
|
||||
key: 'agent:main:lobsterai:session-1',
|
||||
lastChannel: 'dingtalk-connector',
|
||||
lastTo: 'user:legacy-user',
|
||||
lastAccountId: 'legacy-account',
|
||||
deliveryContext: {
|
||||
channel: 'dingtalk-connector',
|
||||
to: 'group:cid-123',
|
||||
accountId: '__default__',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
sessionKey: 'agent:main:lobsterai:session-1',
|
||||
route: {
|
||||
channel: 'dingtalk-connector',
|
||||
to: 'group:cid-123',
|
||||
accountId: '__default__',
|
||||
},
|
||||
});
|
||||
assert.deepEqual(buildDingTalkSendParamsFromRoute(resolved.route), {
|
||||
target: 'group:cid-123',
|
||||
accountId: '__default__',
|
||||
});
|
||||
});
|
||||
|
||||
test('managed session delivery route falls back to last route fields', () => {
|
||||
const resolved = resolveManagedSessionDeliveryRoute('session-2', [
|
||||
{
|
||||
key: 'agent:main:lobsterai:session-2',
|
||||
lastChannel: 'dingtalk-connector',
|
||||
lastTo: 'user:staff-42',
|
||||
lastAccountId: 'acct-1',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
sessionKey: 'agent:main:lobsterai:session-2',
|
||||
route: {
|
||||
channel: 'dingtalk-connector',
|
||||
to: 'user:staff-42',
|
||||
accountId: 'acct-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('route lookup can match DingTalk channel session keys discovered outside the managed session namespace', () => {
|
||||
const candidateSessionKeys = [
|
||||
...buildDingTalkSessionKeyCandidates('__default__:2459325231940374'),
|
||||
'agent:main:lobsterai:session-3',
|
||||
];
|
||||
|
||||
const resolved = resolveOpenClawDeliveryRouteForSessionKeys(candidateSessionKeys, [
|
||||
{
|
||||
key: 'agent:main:openai-user:dingtalk-connector:__default__:2459325231940374',
|
||||
deliveryContext: {
|
||||
channel: 'dingtalk-connector',
|
||||
to: 'group:cid-42',
|
||||
accountId: '__default__',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
sessionKey: 'agent:main:openai-user:dingtalk-connector:__default__:2459325231940374',
|
||||
route: {
|
||||
channel: 'dingtalk-connector',
|
||||
to: 'group:cid-42',
|
||||
accountId: '__default__',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('delivery route extraction ignores incomplete session rows and non-dingtalk channels', () => {
|
||||
assert.equal(extractOpenClawDeliveryRoute({ key: 'agent:main:lobsterai:session-3' }), null);
|
||||
assert.equal(buildDingTalkSendParamsFromRoute({
|
||||
channel: 'telegram',
|
||||
to: 'chat:123',
|
||||
accountId: 'default',
|
||||
}), null);
|
||||
});
|
||||
@@ -49,8 +49,22 @@ test('identifies reminder system turns for async IM delivery', () => {
|
||||
{ type: 'assistant', content: '普通回复' },
|
||||
]), false);
|
||||
|
||||
assert.equal(isReminderSystemTurn([
|
||||
{ type: 'system', content: '⏰ 提醒:喝饮料' },
|
||||
{ type: 'assistant', content: '该喝饮料啦!' },
|
||||
]), true);
|
||||
});
|
||||
|
||||
test('keeps recognizing legacy reminder system messages during transition', () => {
|
||||
assert.equal(isReminderSystemTurn([
|
||||
{ type: 'system', content: 'System: [Sunday, March 15th, 2026 — 4:30 PM] ⏰ 提醒:喝饮料' },
|
||||
{ type: 'assistant', content: '该喝饮料啦!' },
|
||||
]), true);
|
||||
});
|
||||
|
||||
test('recognizes plain reminder text turns during runtime hotfix rollout', () => {
|
||||
assert.equal(isReminderSystemTurn([
|
||||
{ type: 'user', content: '⏰ 提醒:该去钉钉打卡啦!别忘了打卡哦~' },
|
||||
{ type: 'assistant', content: '⏰ 时间到啦,该去打卡了。' },
|
||||
]), true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { IMStore } = require('../dist-electron/main/im/imStore.js');
|
||||
|
||||
class FakeDb {
|
||||
constructor() {
|
||||
this.imConfig = new Map();
|
||||
}
|
||||
|
||||
run(sql, params = []) {
|
||||
if (sql.includes('INSERT INTO im_config')) {
|
||||
this.imConfig.set(String(params[0]), String(params[1]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (sql.includes('INSERT OR REPLACE INTO im_config')) {
|
||||
this.imConfig.set(String(params[0]), String(params[1]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (sql.includes('DELETE FROM im_config WHERE key = ?')) {
|
||||
this.imConfig.delete(String(params[0]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (sql.includes('DELETE FROM im_config')) {
|
||||
this.imConfig.clear();
|
||||
}
|
||||
}
|
||||
|
||||
exec(sql, params = []) {
|
||||
if (sql.includes('SELECT value FROM im_config WHERE key = ?')) {
|
||||
const value = this.imConfig.get(String(params[0]));
|
||||
return value === undefined ? [] : [{ values: [[value]] }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
test('IMStore persists conversation reply routes by platform and conversation ID', () => {
|
||||
const db = new FakeDb();
|
||||
let saveCount = 0;
|
||||
const store = new IMStore(db, () => {
|
||||
saveCount += 1;
|
||||
});
|
||||
|
||||
assert.equal(store.getConversationReplyRoute('dingtalk', '__default__:conv-1'), null);
|
||||
|
||||
store.setConversationReplyRoute('dingtalk', '__default__:conv-1', {
|
||||
channel: 'dingtalk-connector',
|
||||
to: 'group:cid-42',
|
||||
accountId: '__default__',
|
||||
});
|
||||
|
||||
assert.deepEqual(store.getConversationReplyRoute('dingtalk', '__default__:conv-1'), {
|
||||
channel: 'dingtalk-connector',
|
||||
to: 'group:cid-42',
|
||||
accountId: '__default__',
|
||||
});
|
||||
assert.equal(store.getConversationReplyRoute('telegram', '__default__:conv-1'), null);
|
||||
assert.ok(saveCount >= 2);
|
||||
});
|
||||
@@ -171,6 +171,9 @@ test('sync writes native moonshot provider config and migrates matching managed
|
||||
assert.equal(config.models.providers.moonshot.api, 'openai-completions');
|
||||
assert.equal(config.agents.defaults.model.primary, 'moonshot/kimi-k2.5');
|
||||
assert.deepEqual(config.commands.ownerAllowFrom, ['gateway-client']);
|
||||
assert.deepEqual(config.tools.deny, ['web_search']);
|
||||
assert.equal(config.tools.web.search.enabled, false);
|
||||
assert.equal(config.browser.enabled, true);
|
||||
|
||||
const sessionStore = JSON.parse(fs.readFileSync(path.join(sessionsDir, 'sessions.json'), 'utf8'));
|
||||
assert.equal(sessionStore['agent:main:lobsterai:current-session'].modelProvider, 'moonshot');
|
||||
@@ -265,6 +268,11 @@ test('sync writes scheduled-task policy into managed AGENTS.md for native channe
|
||||
|
||||
const agentsMd = fs.readFileSync(path.join(workspaceDir, 'AGENTS.md'), 'utf8');
|
||||
assert.match(agentsMd, /## Scheduled Tasks/);
|
||||
assert.match(agentsMd, /## Web Search/);
|
||||
assert.match(agentsMd, /Built-in `web_search` is disabled in this workspace\./);
|
||||
assert.match(agentsMd, /use `web_fetch`/);
|
||||
assert.match(agentsMd, /use the built-in `browser` tool/);
|
||||
assert.match(agentsMd, /Native channel sessions may deny `exec`/);
|
||||
assert.match(agentsMd, /native `cron` tool/i);
|
||||
assert.match(agentsMd, /action: "add".*cron\.add/i);
|
||||
assert.match(agentsMd, /follow the native `cron` tool schema/i);
|
||||
|
||||
@@ -63,9 +63,22 @@ Handle this reminder internally. Do not relay it to the user unless explicitly r
|
||||
Current time: Sunday, March 15th, 2026 — 11:27 (Asia/Shanghai)`,
|
||||
});
|
||||
|
||||
assert.equal(entry?.role, 'system');
|
||||
assert.match(entry?.text ?? '', /^System: \[Sunday, March 15th, 2026 — 11:27 \(Asia\/Shanghai\)\] ⏰ 提醒:该去买菜了!/);
|
||||
assert.match(entry?.text ?? '', /Please relay this reminder to the user in a helpful and friendly way\./);
|
||||
assert.deepEqual(entry, {
|
||||
role: 'system',
|
||||
text: '⏰ 提醒:该去买菜了!',
|
||||
});
|
||||
});
|
||||
|
||||
test('extractGatewayHistoryEntry remaps plain scheduled reminder text to a system message', () => {
|
||||
const entry = extractGatewayHistoryEntry({
|
||||
role: 'user',
|
||||
content: '⏰ 提醒:该去钉钉打卡啦!别忘了打卡哦~',
|
||||
});
|
||||
|
||||
assert.deepEqual(entry, {
|
||||
role: 'system',
|
||||
text: '⏰ 提醒:该去钉钉打卡啦!别忘了打卡哦~',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildScheduledReminderSystemMessage returns null for regular user text', () => {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import Module from 'node:module';
|
||||
import test from 'node:test';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const originalModuleLoad = Module._load;
|
||||
|
||||
Module._load = function patchedModuleLoad(request, parent, isMain) {
|
||||
if (request === 'electron') {
|
||||
return {
|
||||
app: {
|
||||
getAppPath: () => process.cwd(),
|
||||
getPath: () => process.cwd(),
|
||||
},
|
||||
BrowserWindow: {
|
||||
getAllWindows: () => [],
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalModuleLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
const { OpenClawRuntimeAdapter } = require('../dist-electron/main/libs/agentEngine/openclawRuntimeAdapter.js');
|
||||
|
||||
const createStore = (messages) => {
|
||||
const session = {
|
||||
id: 'session-1',
|
||||
title: 'Channel Session',
|
||||
claudeSessionId: null,
|
||||
status: 'completed',
|
||||
pinned: false,
|
||||
cwd: '',
|
||||
systemPrompt: '',
|
||||
executionMode: 'local',
|
||||
activeSkillIds: [],
|
||||
messages: [...messages],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
let nextId = session.messages.length + 1;
|
||||
|
||||
return {
|
||||
session,
|
||||
store: {
|
||||
getSession: (sessionId) => (sessionId === session.id ? session : null),
|
||||
addMessage: (sessionId, message) => {
|
||||
assert.equal(sessionId, session.id);
|
||||
const created = {
|
||||
id: `msg-${nextId++}`,
|
||||
timestamp: nextId,
|
||||
metadata: {},
|
||||
...message,
|
||||
};
|
||||
session.messages.push(created);
|
||||
return created;
|
||||
},
|
||||
updateSession: () => {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getSystemMessages = (session) => session.messages.filter((message) => message.type === 'system');
|
||||
|
||||
test.after(() => {
|
||||
Module._load = originalModuleLoad;
|
||||
});
|
||||
|
||||
test('syncFullChannelHistory seeds gateway history cursor so old reminders are not replayed', async () => {
|
||||
const { session, store } = createStore([
|
||||
{ id: 'msg-1', type: 'user', content: 'old user', timestamp: 1, metadata: {} },
|
||||
{ id: 'msg-2', type: 'assistant', content: 'old assistant', timestamp: 2, metadata: { isStreaming: false, isFinal: true } },
|
||||
]);
|
||||
const historyMessages = [
|
||||
{ role: 'user', content: 'old user' },
|
||||
{ role: 'assistant', content: 'old assistant' },
|
||||
{ role: 'system', content: 'Reminder: old reminder' },
|
||||
];
|
||||
|
||||
const adapter = new OpenClawRuntimeAdapter(store, {});
|
||||
adapter.gatewayClient = {
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
request: async () => ({ messages: historyMessages }),
|
||||
};
|
||||
|
||||
await adapter.syncFullChannelHistory(session.id, 'dingtalk-connector:acct:user');
|
||||
|
||||
assert.equal(adapter.gatewayHistoryCountBySession.get(session.id), historyMessages.length);
|
||||
|
||||
adapter.syncSystemMessagesFromHistory(session.id, historyMessages, {
|
||||
previousCountKnown: adapter.gatewayHistoryCountBySession.has(session.id),
|
||||
previousCount: adapter.gatewayHistoryCountBySession.get(session.id) ?? 0,
|
||||
});
|
||||
|
||||
assert.equal(getSystemMessages(session).length, 0);
|
||||
});
|
||||
|
||||
test('prefetchChannelUserMessages also consumes existing reminder history backlog', async () => {
|
||||
const { session, store } = createStore([
|
||||
{ id: 'msg-1', type: 'user', content: 'old user', timestamp: 1, metadata: {} },
|
||||
{ id: 'msg-2', type: 'assistant', content: 'old assistant', timestamp: 2, metadata: { isStreaming: false, isFinal: true } },
|
||||
]);
|
||||
const historyMessages = [
|
||||
{ role: 'user', content: 'old user' },
|
||||
{ role: 'assistant', content: 'old assistant' },
|
||||
{ role: 'system', content: 'Reminder: old reminder' },
|
||||
{ role: 'user', content: 'new user turn' },
|
||||
];
|
||||
|
||||
const adapter = new OpenClawRuntimeAdapter(store, {});
|
||||
adapter.gatewayClient = {
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
request: async () => ({ messages: historyMessages }),
|
||||
};
|
||||
|
||||
await adapter.prefetchChannelUserMessages(session.id, 'dingtalk-connector:acct:user');
|
||||
|
||||
assert.equal(adapter.gatewayHistoryCountBySession.get(session.id), historyMessages.length);
|
||||
assert.equal(session.messages.filter((message) => message.type === 'user').length, 2);
|
||||
|
||||
adapter.syncSystemMessagesFromHistory(session.id, historyMessages, {
|
||||
previousCountKnown: adapter.gatewayHistoryCountBySession.has(session.id),
|
||||
previousCount: adapter.gatewayHistoryCountBySession.get(session.id) ?? 0,
|
||||
});
|
||||
|
||||
assert.equal(getSystemMessages(session).length, 0);
|
||||
});
|
||||
|
||||
test('getSessionKeysForSession prefers channel keys before managed fallback', () => {
|
||||
const { store } = createStore([]);
|
||||
const adapter = new OpenClawRuntimeAdapter(store, {});
|
||||
|
||||
adapter.rememberSessionKey('session-1', 'agent:main:openai-user:dingtalk-connector:__default__:2459325231940374');
|
||||
adapter.rememberSessionKey('session-1', 'agent:main:lobsterai:session-1');
|
||||
|
||||
assert.deepEqual(adapter.getSessionKeysForSession('session-1'), [
|
||||
'agent:main:openai-user:dingtalk-connector:__default__:2459325231940374',
|
||||
'agent:main:lobsterai:session-1',
|
||||
]);
|
||||
});
|
||||
@@ -8,6 +8,9 @@ import path from 'node:path';
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
applyBundledOpenClawRuntimeHotfixes,
|
||||
patchCronReminderCurrentTimeSuffix,
|
||||
patchCronReminderPromptEnvelope,
|
||||
patchCronSessionDeliveryInference,
|
||||
patchCronToolOwnerOnly,
|
||||
patchCronOwnerFallback,
|
||||
patchWecomMessageProviderExecDeny,
|
||||
@@ -81,6 +84,80 @@ test('patchWecomMessageProviderExecDeny denies exec/process for WeCom native ses
|
||||
);
|
||||
});
|
||||
|
||||
test('patchCronSessionDeliveryInference restores IM routing for persisted sessions and DingTalk keys', () => {
|
||||
const source = [
|
||||
'function inferDeliveryFromSessionKey(agentSessionKey) {',
|
||||
' const rawSessionKey = agentSessionKey?.trim();',
|
||||
' if (!rawSessionKey) return null;',
|
||||
' const parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));',
|
||||
' if (!parsed || !parsed.rest) return null;',
|
||||
' const parts = parsed.rest.split(":").filter(Boolean);',
|
||||
' if (parts.length === 0) return null;',
|
||||
' const head = parts[0]?.trim().toLowerCase();',
|
||||
' if (!head || head === "main" || head === "subagent" || head === "acp") return null;',
|
||||
' const markerIndex = parts.findIndex((part) => part === "direct" || part === "dm" || part === "group" || part === "channel");',
|
||||
' if (markerIndex === -1) return null;',
|
||||
' const peerId = parts.slice(markerIndex + 1).join(":").trim();',
|
||||
' if (!peerId) return null;',
|
||||
' let channel;',
|
||||
' if (markerIndex >= 1) channel = parts[0]?.trim().toLowerCase();',
|
||||
' const delivery = {',
|
||||
' mode: "announce",',
|
||||
' to: peerId',
|
||||
' };',
|
||||
' if (channel) delivery.channel = channel;',
|
||||
' return delivery;',
|
||||
'}',
|
||||
].join('\n').replaceAll(' ', '\t');
|
||||
|
||||
const result = patchCronSessionDeliveryInference(source);
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.match(result.content, /extractDeliveryInfo\(rawSessionKey\)/);
|
||||
assert.match(result.content, /persistedDelivery\?\.channel && persistedDelivery\?\.to/);
|
||||
assert.match(result.content, /head === "dingtalk-connector"/);
|
||||
assert.match(result.content, /to: `user:\$\{senderId\}`/);
|
||||
assert.match(result.content, /delivery\.accountId = persistedDelivery\.accountId/);
|
||||
});
|
||||
|
||||
test('patchCronReminderPromptEnvelope strips scheduled reminder wrapper text', () => {
|
||||
const source = [
|
||||
'function buildCronEventPrompt(pendingEvents, opts) {',
|
||||
'\tconst deliverToUser = opts?.deliverToUser ?? true;',
|
||||
'\tconst eventText = pendingEvents.join("\\n").trim();',
|
||||
'\tif (!eventText) {',
|
||||
'\t\tif (!deliverToUser) return "A scheduled cron event was triggered, but no event content was found. Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up.";',
|
||||
'\t\treturn "A scheduled cron event was triggered, but no event content was found. Reply HEARTBEAT_OK.";',
|
||||
'\t}',
|
||||
'\tif (!deliverToUser) return "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nHandle this reminder internally. Do not relay it to the user unless explicitly requested.";',
|
||||
'\treturn "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nPlease relay this reminder to the user in a helpful and friendly way.";',
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
const result = patchCronReminderPromptEnvelope(source);
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.match(result.content, /return eventText;/);
|
||||
assert.doesNotMatch(result.content, /A scheduled reminder has been triggered/);
|
||||
});
|
||||
|
||||
test('patchCronReminderCurrentTimeSuffix skips appending Current time for cron reminder prompts', () => {
|
||||
const source = [
|
||||
'const hasCronEvents = true;',
|
||||
'const ctx = {',
|
||||
'\tBody: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),',
|
||||
'};',
|
||||
].join('\n');
|
||||
|
||||
const result = patchCronReminderCurrentTimeSuffix(source);
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.match(
|
||||
result.content,
|
||||
/Body: hasCronEvents \? prompt : appendCronStyleCurrentTimeLine\(prompt, cfg, startedAt\),/,
|
||||
);
|
||||
});
|
||||
|
||||
test('applyBundledOpenClawRuntimeHotfixes patches matching runtime dist files', () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-runtime-hotfix-'));
|
||||
const distDir = path.join(tmpRoot, 'dist', 'plugin-sdk');
|
||||
@@ -103,6 +180,42 @@ test('applyBundledOpenClawRuntimeHotfixes patches matching runtime dist files',
|
||||
' "gateway"',
|
||||
']);',
|
||||
'const TOOL_DENY_BY_MESSAGE_PROVIDER = { voice: ["tts"] };',
|
||||
'function inferDeliveryFromSessionKey(agentSessionKey) {',
|
||||
'\tconst rawSessionKey = agentSessionKey?.trim();',
|
||||
'\tif (!rawSessionKey) return null;',
|
||||
'\tconst parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));',
|
||||
'\tif (!parsed || !parsed.rest) return null;',
|
||||
'\tconst parts = parsed.rest.split(":").filter(Boolean);',
|
||||
'\tif (parts.length === 0) return null;',
|
||||
'\tconst head = parts[0]?.trim().toLowerCase();',
|
||||
'\tif (!head || head === "main" || head === "subagent" || head === "acp") return null;',
|
||||
'\tconst markerIndex = parts.findIndex((part) => part === "direct" || part === "dm" || part === "group" || part === "channel");',
|
||||
'\tif (markerIndex === -1) return null;',
|
||||
'\tconst peerId = parts.slice(markerIndex + 1).join(":").trim();',
|
||||
'\tif (!peerId) return null;',
|
||||
'\tlet channel;',
|
||||
'\tif (markerIndex >= 1) channel = parts[0]?.trim().toLowerCase();',
|
||||
'\tconst delivery = {',
|
||||
'\t\tmode: "announce",',
|
||||
'\t\tto: peerId',
|
||||
'\t};',
|
||||
'\tif (channel) delivery.channel = channel;',
|
||||
'\treturn delivery;',
|
||||
'}',
|
||||
'function buildCronEventPrompt(pendingEvents, opts) {',
|
||||
'\tconst deliverToUser = opts?.deliverToUser ?? true;',
|
||||
'\tconst eventText = pendingEvents.join("\\n").trim();',
|
||||
'\tif (!eventText) {',
|
||||
'\t\tif (!deliverToUser) return "A scheduled cron event was triggered, but no event content was found. Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up.";',
|
||||
'\t\treturn "A scheduled cron event was triggered, but no event content was found. Reply HEARTBEAT_OK.";',
|
||||
'\t}',
|
||||
'\tif (!deliverToUser) return "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nHandle this reminder internally. Do not relay it to the user unless explicitly requested.";',
|
||||
'\treturn "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nPlease relay this reminder to the user in a helpful and friendly way.";',
|
||||
'}',
|
||||
'const hasCronEvents = true;',
|
||||
'const ctx = {',
|
||||
'\tBody: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),',
|
||||
'};',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
@@ -132,6 +245,13 @@ test('applyBundledOpenClawRuntimeHotfixes patches matching runtime dist files',
|
||||
fs.readFileSync(targetFile, 'utf8'),
|
||||
/TOOL_DENY_BY_MESSAGE_PROVIDER = \{ voice: \["tts"\], wecom: \["exec", "process"\] \};/,
|
||||
);
|
||||
assert.match(fs.readFileSync(targetFile, 'utf8'), /extractDeliveryInfo\(rawSessionKey\)/);
|
||||
assert.match(fs.readFileSync(targetFile, 'utf8'), /head === "dingtalk-connector"/);
|
||||
assert.match(fs.readFileSync(targetFile, 'utf8'), /return eventText;/);
|
||||
assert.match(
|
||||
fs.readFileSync(targetFile, 'utf8'),
|
||||
/Body: hasCronEvents \? prompt : appendCronStyleCurrentTimeLine\(prompt, cfg, startedAt\),/,
|
||||
);
|
||||
assert.match(fs.readFileSync(controlFile, 'utf8'), /ownerOnly: true/);
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -21,6 +21,6 @@
|
||||
"@/*": ["src/renderer/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/renderer"],
|
||||
"include": ["src/renderer", "src/common"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user