fix: deduplicate CI watchers and remove task ID from watcher prompts
Prevent duplicate CI completion notifications in paired rooms by checking for existing active watchers with the same channel+provider+metadata before creating a new one. Remove Task ID from watcher prompt construction to avoid router secret redaction (task- IDs contain sk- pattern), passing EJCLAW_RUNTIME_TASK_ID via env var instead and making cancel_task auto-resolve when task_id is omitted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ const chatJid = process.env.EJCLAW_CHAT_JID!;
|
|||||||
const groupFolder = process.env.EJCLAW_GROUP_FOLDER!;
|
const groupFolder = process.env.EJCLAW_GROUP_FOLDER!;
|
||||||
const isMain = process.env.EJCLAW_IS_MAIN === '1';
|
const isMain = process.env.EJCLAW_IS_MAIN === '1';
|
||||||
const agentType = process.env.EJCLAW_AGENT_TYPE || 'claude-code';
|
const agentType = process.env.EJCLAW_AGENT_TYPE || 'claude-code';
|
||||||
|
const runtimeTaskId = process.env.EJCLAW_RUNTIME_TASK_ID;
|
||||||
const allowGenericScheduling = agentType !== 'codex';
|
const allowGenericScheduling = agentType !== 'codex';
|
||||||
|
|
||||||
function writeIpcFile(dir: string, data: object): string {
|
function writeIpcFile(dir: string, data: object): string {
|
||||||
@@ -299,7 +300,6 @@ server.tool(
|
|||||||
isMain && args.target_group_jid ? args.target_group_jid : chatJid;
|
isMain && args.target_group_jid ? args.target_group_jid : chatJid;
|
||||||
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const prompt = buildCiWatchPrompt({
|
const prompt = buildCiWatchPrompt({
|
||||||
taskId,
|
|
||||||
target,
|
target,
|
||||||
checkInstructions,
|
checkInstructions,
|
||||||
});
|
});
|
||||||
@@ -329,7 +329,7 @@ server.tool(
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
text: `CI watcher scheduled: ${taskId} (${pollSeconds}s)`,
|
text: `CI watcher scheduled for ${target} (${pollSeconds}s interval)`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -414,12 +414,20 @@ server.tool(
|
|||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'cancel_task',
|
'cancel_task',
|
||||||
'Cancel and delete a scheduled task.',
|
'Cancel and delete a scheduled task. If no task_id is provided, cancels the current task (for background watchers).',
|
||||||
{ task_id: z.string().describe('The task ID to cancel') },
|
{ task_id: z.string().optional().describe('The task ID to cancel. Omit to cancel the current task.') },
|
||||||
async (args: { task_id: string }) => {
|
async (args: { task_id?: string }) => {
|
||||||
|
const resolvedId = args.task_id || runtimeTaskId;
|
||||||
|
if (!resolvedId) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text' as const, text: 'No task_id provided and no current task context available.' }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
type: 'cancel_task',
|
type: 'cancel_task',
|
||||||
taskId: args.task_id,
|
taskId: resolvedId,
|
||||||
groupFolder,
|
groupFolder,
|
||||||
isMain,
|
isMain,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -427,7 +435,7 @@ server.tool(
|
|||||||
|
|
||||||
writeIpcFile(TASKS_DIR, data);
|
writeIpcFile(TASKS_DIR, data);
|
||||||
|
|
||||||
return { content: [{ type: 'text' as const, text: `Task ${args.task_id} cancellation requested.` }] };
|
return { content: [{ type: 'text' as const, text: `Task cancellation requested.` }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export interface NormalizeWatchCiIntervalOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildCiWatchPromptArgs {
|
export interface BuildCiWatchPromptArgs {
|
||||||
taskId: string;
|
|
||||||
target: string;
|
target: string;
|
||||||
checkInstructions: string;
|
checkInstructions: string;
|
||||||
}
|
}
|
||||||
@@ -48,7 +47,6 @@ export function normalizeWatchCiIntervalSeconds(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildCiWatchPrompt({
|
export function buildCiWatchPrompt({
|
||||||
taskId,
|
|
||||||
target,
|
target,
|
||||||
checkInstructions,
|
checkInstructions,
|
||||||
}: BuildCiWatchPromptArgs): string {
|
}: BuildCiWatchPromptArgs): string {
|
||||||
@@ -60,9 +58,6 @@ You are running as an EJClaw background CI watcher.
|
|||||||
Watch target:
|
Watch target:
|
||||||
${target}
|
${target}
|
||||||
|
|
||||||
Task ID:
|
|
||||||
${taskId}
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
${checkInstructions}
|
${checkInstructions}
|
||||||
|
|
||||||
@@ -81,7 +76,7 @@ Rules:
|
|||||||
4. Adapt the content to the specific CI. Do not invent fixed fields when they do not fit.
|
4. Adapt the content to the specific CI. Do not invent fixed fields when they do not fit.
|
||||||
5. Avoid tables unless they are clearly the shortest readable format.
|
5. Avoid tables unless they are clearly the shortest readable format.
|
||||||
6. Keep the message compact and easy for other agents to parse.
|
6. Keep the message compact and easy for other agents to parse.
|
||||||
7. Call \`cancel_task\` with task_id "${taskId}" so this watcher stops itself.
|
7. Call \`cancel_task\` (no arguments needed) so this watcher stops itself.
|
||||||
- If you hit a transient problem such as a rate limit, network issue, or temporary auth failure, send no visible message and leave the task active for the next retry.
|
- If you hit a transient problem such as a rate limit, network issue, or temporary auth failure, send no visible message and leave the task active for the next retry.
|
||||||
- Prefer no normal final response. Use \`send_message\` for the completion message, and keep any non-user-facing notes inside \`<internal>\` tags if needed.
|
- Prefer no normal final response. Use \`send_message\` for the completion message, and keep any non-user-facing notes inside \`<internal>\` tags if needed.
|
||||||
- Do not claim continued monitoring after you cancel the task.
|
- Do not claim continued monitoring after you cancel the task.
|
||||||
|
|||||||
@@ -10,14 +10,13 @@ import {
|
|||||||
describe('watch-ci helpers', () => {
|
describe('watch-ci helpers', () => {
|
||||||
it('builds a self-cancelling CI watch prompt', () => {
|
it('builds a self-cancelling CI watch prompt', () => {
|
||||||
const prompt = buildCiWatchPrompt({
|
const prompt = buildCiWatchPrompt({
|
||||||
taskId: 'task-123',
|
|
||||||
target: 'PR #42 checks',
|
target: 'PR #42 checks',
|
||||||
checkInstructions:
|
checkInstructions:
|
||||||
'Use gh pr checks 42 and summarize only terminal results.',
|
'Use gh pr checks 42 and summarize only terminal results.',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt).toContain('PR #42 checks');
|
expect(prompt).toContain('PR #42 checks');
|
||||||
expect(prompt).toContain('task-123');
|
expect(prompt).not.toContain('Task ID:');
|
||||||
expect(prompt).toContain('cancel_task');
|
expect(prompt).toContain('cancel_task');
|
||||||
expect(prompt).toContain('send_message');
|
expect(prompt).toContain('send_message');
|
||||||
expect(prompt).toContain('gh pr checks 42');
|
expect(prompt).toContain('gh pr checks 42');
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function buildBaseRunnerEnv(args: {
|
|||||||
groupSessionsDir: string;
|
groupSessionsDir: string;
|
||||||
agentType: AgentType;
|
agentType: AgentType;
|
||||||
envVars: Record<string, string>;
|
envVars: Record<string, string>;
|
||||||
|
runtimeTaskId?: string;
|
||||||
}): Record<string, string> {
|
}): Record<string, string> {
|
||||||
const cleanEnv = { ...(process.env as Record<string, string>) };
|
const cleanEnv = { ...(process.env as Record<string, string>) };
|
||||||
for (const [key, value] of Object.entries(args.envVars)) {
|
for (const [key, value] of Object.entries(args.envVars)) {
|
||||||
@@ -103,6 +104,9 @@ function buildBaseRunnerEnv(args: {
|
|||||||
EJCLAW_IS_MAIN: args.isMain ? '1' : '0',
|
EJCLAW_IS_MAIN: args.isMain ? '1' : '0',
|
||||||
EJCLAW_AGENT_TYPE: args.agentType,
|
EJCLAW_AGENT_TYPE: args.agentType,
|
||||||
CLAUDE_CONFIG_DIR: args.groupSessionsDir,
|
CLAUDE_CONFIG_DIR: args.groupSessionsDir,
|
||||||
|
...(args.runtimeTaskId
|
||||||
|
? { EJCLAW_RUNTIME_TASK_ID: args.runtimeTaskId }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +426,7 @@ export function prepareGroupEnvironment(
|
|||||||
groupSessionsDir,
|
groupSessionsDir,
|
||||||
agentType,
|
agentType,
|
||||||
envVars,
|
envVars,
|
||||||
|
runtimeTaskId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (agentType === 'codex') {
|
if (agentType === 'codex') {
|
||||||
|
|||||||
@@ -469,9 +469,6 @@ describe('task CRUD', () => {
|
|||||||
Watch target:
|
Watch target:
|
||||||
cleanup
|
cleanup
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-cleanup
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Check the run.
|
Check the run.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
|
|||||||
19
src/db.ts
19
src/db.ts
@@ -936,6 +936,25 @@ export function getTaskById(id: string): ScheduledTask | undefined {
|
|||||||
| undefined;
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an existing active/paused CI watcher for the same channel + provider + metadata.
|
||||||
|
* Used to prevent duplicate watchers when both agents register for the same CI run.
|
||||||
|
*/
|
||||||
|
export function findDuplicateCiWatcher(
|
||||||
|
chatJid: string,
|
||||||
|
ciProvider: string,
|
||||||
|
ciMetadata: string,
|
||||||
|
): ScheduledTask | undefined {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM scheduled_tasks
|
||||||
|
WHERE chat_jid = ? AND ci_provider = ? AND ci_metadata = ?
|
||||||
|
AND status IN ('active', 'paused')
|
||||||
|
LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get(chatJid, ciProvider, ciMetadata) as ScheduledTask | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function getTasksForGroup(
|
export function getTasksForGroup(
|
||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
agentType?: AgentType,
|
agentType?: AgentType,
|
||||||
|
|||||||
@@ -62,9 +62,6 @@ describe('github-ci helpers', () => {
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 123456
|
GitHub Actions run 123456
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-github
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Managed by host-driven watcher.
|
Managed by host-driven watcher.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
@@ -128,9 +125,6 @@ Managed by host-driven watcher.
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 654321
|
GitHub Actions run 654321
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-github
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Managed by host-driven watcher.
|
Managed by host-driven watcher.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
|
|||||||
@@ -563,9 +563,6 @@ describe('schedule_task schedule types', () => {
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 123456
|
GitHub Actions run 123456
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-watch-ci
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Check the run.
|
Check the run.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
@@ -598,9 +595,6 @@ Check the run.
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 654321
|
GitHub Actions run 654321
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-watch-github
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Managed by host-driven watcher.
|
Managed by host-driven watcher.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
|
|||||||
30
src/ipc.ts
30
src/ipc.ts
@@ -11,7 +11,13 @@ import {
|
|||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { readJsonFile } from './utils.js';
|
import { readJsonFile } from './utils.js';
|
||||||
import { AvailableGroup } from './agent-runner.js';
|
import { AvailableGroup } from './agent-runner.js';
|
||||||
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
|
import {
|
||||||
|
createTask,
|
||||||
|
deleteTask,
|
||||||
|
findDuplicateCiWatcher,
|
||||||
|
getTaskById,
|
||||||
|
updateTask,
|
||||||
|
} from './db.js';
|
||||||
import { isValidGroupFolder } from './group-folder.js';
|
import { isValidGroupFolder } from './group-folder.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
@@ -394,6 +400,28 @@ export async function processTaskIpc(
|
|||||||
nextRun = date.toISOString();
|
nextRun = date.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deduplicate CI watchers: if another agent already watches the same
|
||||||
|
// channel + provider + run, skip creation to avoid duplicate notifications.
|
||||||
|
if (data.ci_provider && data.ci_metadata) {
|
||||||
|
const existing = findDuplicateCiWatcher(
|
||||||
|
resolvedTargetJid,
|
||||||
|
data.ci_provider,
|
||||||
|
data.ci_metadata as string,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
existingTaskId: existing.id,
|
||||||
|
existingAgentType: existing.agent_type,
|
||||||
|
ciProvider: data.ci_provider,
|
||||||
|
sourceGroup,
|
||||||
|
},
|
||||||
|
'Duplicate CI watcher skipped — another agent already watches this run',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const taskId =
|
const taskId =
|
||||||
data.taskId ||
|
data.taskId ||
|
||||||
`task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
`task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|||||||
@@ -326,9 +326,6 @@ describe('task scheduler', () => {
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 123456
|
GitHub Actions run 123456
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-watch-group
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Check the run.
|
Check the run.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
@@ -890,9 +887,6 @@ Check the run.
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 654321
|
GitHub Actions run 654321
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-watch-immediate
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Check the run.
|
Check the run.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
@@ -928,9 +922,6 @@ Check the run.
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 123456
|
GitHub Actions run 123456
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-watch-runtime
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Check the run.
|
Check the run.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
@@ -1004,9 +995,6 @@ Check the run.
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 123456
|
GitHub Actions run 123456
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-github-running
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Managed by host-driven watcher.
|
Managed by host-driven watcher.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
@@ -1082,9 +1070,6 @@ Managed by host-driven watcher.
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 654321
|
GitHub Actions run 654321
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-github-complete
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Managed by host-driven watcher.
|
Managed by host-driven watcher.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
@@ -1149,9 +1134,6 @@ Managed by host-driven watcher.
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 222222
|
GitHub Actions run 222222
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-github-backoff
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Managed by host-driven watcher.
|
Managed by host-driven watcher.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
@@ -1220,9 +1202,6 @@ Managed by host-driven watcher.
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 333333
|
GitHub Actions run 333333
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-github-pause
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Managed by host-driven watcher.
|
Managed by host-driven watcher.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
@@ -1284,9 +1263,6 @@ Managed by host-driven watcher.
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 999999
|
GitHub Actions run 999999
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-watch-expired
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Check the run.
|
Check the run.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
@@ -1389,9 +1365,6 @@ Check the run.
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 123456
|
GitHub Actions run 123456
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-123
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Check the run.
|
Check the run.
|
||||||
`.trim();
|
`.trim();
|
||||||
@@ -1428,9 +1401,6 @@ Check the run.
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 123456
|
GitHub Actions run 123456
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-123
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Check the run.
|
Check the run.
|
||||||
`.trim();
|
`.trim();
|
||||||
@@ -1463,9 +1433,6 @@ Check the run.
|
|||||||
Watch target:
|
Watch target:
|
||||||
GitHub Actions run 123456
|
GitHub Actions run 123456
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-watch-status
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Check the run.
|
Check the run.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
@@ -1527,9 +1494,6 @@ Check the run.
|
|||||||
Watch target:
|
Watch target:
|
||||||
PR #77 checks
|
PR #77 checks
|
||||||
|
|
||||||
Task ID:
|
|
||||||
task-watch-status-edit-fail
|
|
||||||
|
|
||||||
Check instructions:
|
Check instructions:
|
||||||
Check the run.
|
Check the run.
|
||||||
`.trim(),
|
`.trim(),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function isTaskStatusControlMessage(content: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function extractWatchCiTarget(prompt: string): string | null {
|
export function extractWatchCiTarget(prompt: string): string | null {
|
||||||
const match = prompt.match(/Watch target:\n([\s\S]*?)\n\nTask ID:/);
|
const match = prompt.match(/Watch target:\n([\s\S]*?)\n\nCheck instructions:/);
|
||||||
return match?.[1]?.trim() || null;
|
return match?.[1]?.trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,17 @@ describe('summarizeWatcherTasks', () => {
|
|||||||
const summary = summarizeWatcherTasks([
|
const summary = summarizeWatcherTasks([
|
||||||
{
|
{
|
||||||
prompt:
|
prompt:
|
||||||
'[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 1\n\nTask ID:\na',
|
'[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 1\n\nCheck instructions:\na',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prompt:
|
prompt:
|
||||||
'[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 2\n\nTask ID:\nb',
|
'[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 2\n\nCheck instructions:\nb',
|
||||||
status: 'paused',
|
status: 'paused',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
prompt:
|
prompt:
|
||||||
'[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 3\n\nTask ID:\nc',
|
'[BACKGROUND CI WATCH]\n\nWatch target:\nGitHub Actions run 3\n\nCheck instructions:\nc',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user