package vip.mate.cron; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import vip.mate.channel.ChannelSessionStore; import vip.mate.channel.model.ChannelSessionEntity; import vip.mate.cron.model.CronJobEntity; import vip.mate.cron.model.DeliveryConfig; import java.util.List; import java.util.Optional; /** * Single source of truth for the {@code conversationId} a cron run writes to. *

* Cron used to write every run to a per-job orphan conversation * ({@code "cron_" + job.getId()}). Those rows existed in {@code mate_conversation} * but had no entry in any sidebar โ€” the user had no way to reach them. The * delivery pipeline ({@code CronResultDelivery}) covered the IM case (push * back to DingTalk / Feishu / etc.) but Web-origin cron jobs ended up with * {@code delivery_status='NONE'} and silent results. *

* The new policy: *

* * @author MateClaw Team */ @Slf4j @Component @RequiredArgsConstructor public class CronConversationResolver { private final ChannelSessionStore channelSessionStore; public String resolve(CronJobEntity job) { if (job == null) return "tasks_1"; // IM-bound cron: try to thread output into the existing channel session // so the IM mirror in Web Console shows it inline with regular chat. if (job.getChannelId() != null) { String sessionConvId = findChannelSessionConvId(job); if (sessionConvId != null) return sessionConvId; // No session yet โ€” keep the legacy per-job conversation so push // delivery to the IM still works and the run isn't dropped. return "cron_" + job.getId(); } // Web-origin cron: unified per-workspace tasks conversation. Long ws = job.getWorkspaceId() != null ? job.getWorkspaceId() : 1L; return "tasks_" + ws; } /** * Find the existing channel session for the cron's creator. Match * priority โ€” most specific first: *
    *
  1. {@code (channelId, dc.userId)} โ€” the senderId of who created * this cron, captured by {@code CronJobTool.propagateChannelBinding}. * This is the stable identifier across replyToken rotations.
  2. *
  3. {@code (channelId, dc.targetId)} โ€” fallback for legacy rows that * were written before the {@code userId} field was added (V62 * baseline / older). Matches when the channel adapter happens to * use the same value for {@code session.targetId} and * {@code dc.targetId} (Slack / Discord / Telegram). Will miss for * DingTalk-style replyToken adapters but those rows will never * have written a useful targetId match either, so behavior is * no worse than before.
  4. *
  5. If both miss, return null and fall back to {@code cron_}.
  6. *
*/ private String findChannelSessionConvId(CronJobEntity job) { DeliveryConfig dc = job.getDeliveryConfig(); if (dc == null) return null; try { List sessions = channelSessionStore.listByChannelId(job.getChannelId()); if (sessions.isEmpty()) return null; // Preferred: match by creator's senderId (V63+ rows). if (dc.userId() != null && !dc.userId().isBlank()) { String byUser = sessions.stream() .filter(s -> dc.userId().equals(s.getSenderId())) .map(ChannelSessionEntity::getConversationId) .findFirst() .orElse(null); if (byUser != null) return byUser; } // Fallback: legacy targetId match (works for non-replyToken adapters). if (dc.targetId() != null && !dc.targetId().isBlank()) { return sessions.stream() .filter(s -> dc.targetId().equals(s.getTargetId())) .map(ChannelSessionEntity::getConversationId) .findFirst() .orElse(null); } return null; } catch (Exception e) { log.debug("[CronConvResolver] session lookup failed for job {}: {}", job.getId(), e.getMessage()); return null; } } /** Reused by header insertion to know whether we are in the unified tasks view. */ public boolean isWebOriginTasksConv(String conversationId) { return conversationId != null && conversationId.startsWith("tasks_"); } }