mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-15 18:21:47 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 561a0b6217 | |||
| ccb4523a54 | |||
| e87d15db96 |
@@ -1,153 +0,0 @@
|
||||
---
|
||||
name: add-github
|
||||
description: Add GitHub skill to container agents. Installs gh CLI in the container, passes GITHUB_TOKEN via stdin, and adds the agent-github skill doc so agents can manage issues, PRs, and code review.
|
||||
---
|
||||
|
||||
# Add GitHub Skill
|
||||
|
||||
This skill gives container agents access to the GitHub CLI (`gh`) for managing issues, pull requests, and code review.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Read `.nanoclaw/state.yaml`. If `github` is in `applied_skills`, skip to Phase 3 (Configure). The code changes are already in place.
|
||||
|
||||
### Ask the user
|
||||
|
||||
Use `AskUserQuestion` to collect the GitHub token:
|
||||
|
||||
AskUserQuestion: Do you have a GitHub personal access token, or do you need to create one?
|
||||
|
||||
If they have one, collect it now. If not, guide them in Phase 3.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Initialize skills system (if needed)
|
||||
|
||||
If `.nanoclaw/` directory doesn't exist yet:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts --init
|
||||
```
|
||||
|
||||
### Apply the skill
|
||||
|
||||
```bash
|
||||
npx tsx scripts/apply-skill.ts .claude/skills/add-github
|
||||
```
|
||||
|
||||
This deterministically:
|
||||
- Adds `container/skills/agent-github/SKILL.md` (agent-facing GitHub CLI docs)
|
||||
- Three-way merges `gh` CLI installation into `container/Dockerfile`
|
||||
- Three-way merges `GITHUB_TOKEN` and `GH_REPO` into `readSecrets()` in `src/container-runner.ts`
|
||||
- Records the application in `.nanoclaw/state.yaml`
|
||||
|
||||
If the apply reports merge conflicts, read the intent files:
|
||||
- `modify/container/Dockerfile.intent.md` — what changed for the Dockerfile
|
||||
- `modify/src/container-runner.ts.intent.md` — what changed for container-runner.ts
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Configure Token and Repository
|
||||
|
||||
### Step 1: Ask for token or guide creation
|
||||
|
||||
Use `AskUserQuestion`:
|
||||
|
||||
AskUserQuestion: Do you have a GitHub personal access token already, or do you need to create one?
|
||||
|
||||
If they need to create one, tell them:
|
||||
|
||||
> Create a GitHub personal access token (classic):
|
||||
>
|
||||
> 1. Go to https://github.com/settings/tokens/new
|
||||
> 2. **Note**: `NanoClaw agent` (or whatever you like)
|
||||
> 3. **Expiration**: 90 days (recommended) or "No expiration"
|
||||
> 4. **Scopes**: select `repo` (full repo access — covers issues, PRs, code, status)
|
||||
> 5. Click **Generate token** and copy it immediately (you can only see it once)
|
||||
>
|
||||
> The token will start with `ghp_...`. Paste it here when you have it.
|
||||
|
||||
Wait for the user to provide the token.
|
||||
|
||||
### Step 2: Ask for target repository
|
||||
|
||||
Use `AskUserQuestion`:
|
||||
|
||||
AskUserQuestion: Which GitHub repository should the agent manage? (format: `owner/repo`)
|
||||
|
||||
### Step 3: Add to `.env`
|
||||
|
||||
Add both values to `.env`:
|
||||
|
||||
```bash
|
||||
GITHUB_TOKEN=ghp_...
|
||||
GH_REPO=owner/repo
|
||||
```
|
||||
|
||||
`GH_REPO` sets the default repository so the agent doesn't need `--repo` on every command.
|
||||
|
||||
### Step 4: Sync env and restart
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
./container/build.sh
|
||||
rm -r data/sessions/*/agent-runner-src 2>/dev/null || true
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Test the connection
|
||||
|
||||
Tell the user:
|
||||
|
||||
> GitHub is connected! Send this in your main channel:
|
||||
>
|
||||
> "List the open issues on our repo"
|
||||
|
||||
The agent should invoke `gh issue list` and return results.
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### gh: not found
|
||||
|
||||
The container needs to be rebuilt with the `gh` CLI:
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
rm -r data/sessions/*/agent-runner-src 2>/dev/null || true
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
|
||||
### gh: authentication required
|
||||
|
||||
- Check `GITHUB_TOKEN` is set in `.env`
|
||||
- Restart NanoClaw
|
||||
|
||||
### Rate limit exceeded
|
||||
|
||||
GitHub allows 5,000 requests/hour with token auth. If hitting limits, the agent is likely making too many API calls in a loop.
|
||||
|
||||
## Removal
|
||||
|
||||
1. Delete `container/skills/agent-github/SKILL.md`
|
||||
2. Remove `gh` installation block from `container/Dockerfile`
|
||||
3. Remove `GITHUB_TOKEN` and `GH_REPO` from `readSecrets()` in `src/container-runner.ts`
|
||||
4. Remove `GITHUB_TOKEN` and `GH_REPO` from `.env`
|
||||
5. Remove `github` from `.nanoclaw/state.yaml`
|
||||
6. Rebuild: `./container/build.sh && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
name: agent-github
|
||||
description: Manage GitHub issues, pull requests, and code review for the repository. Use for any GitHub task — listing issues, reviewing PRs, checking CI status, adding labels, posting comments.
|
||||
allowed-tools: Bash(gh:*)
|
||||
---
|
||||
|
||||
# GitHub CLI (`gh`)
|
||||
|
||||
## Authentication
|
||||
|
||||
`gh` is pre-authenticated via `GITHUB_TOKEN` env var. The target repo is set via `GH_REPO` (e.g., `owner/repo`), so you don't need to specify `--repo` on every command.
|
||||
|
||||
## Read operations
|
||||
|
||||
### Issues
|
||||
|
||||
```bash
|
||||
gh issue list # Open issues
|
||||
gh issue list --state closed --limit 10 # Recent closed issues
|
||||
gh issue list --label "bug" # Filter by label
|
||||
gh issue list --assignee "@me" # Assigned to me
|
||||
gh issue list --search "keyword" # Search issues
|
||||
gh issue view 42 # View issue details
|
||||
gh issue view 42 --comments # Include comments
|
||||
```
|
||||
|
||||
### Pull requests
|
||||
|
||||
```bash
|
||||
gh pr list # Open PRs
|
||||
gh pr list --state merged --limit 10 # Recent merged PRs
|
||||
gh pr list --author "username" # Filter by author
|
||||
gh pr view 99 # View PR details
|
||||
gh pr view 99 --comments # Include comments
|
||||
gh pr diff 99 # View PR diff
|
||||
gh pr checks 99 # CI/check status
|
||||
```
|
||||
|
||||
### Repository info
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/branches # List branches
|
||||
gh api repos/{owner}/{repo}/commits?per_page=10 # Recent commits
|
||||
gh api repos/{owner}/{repo}/releases/latest # Latest release
|
||||
gh api repos/{owner}/{repo}/actions/runs?per_page=5 # Recent workflow runs
|
||||
```
|
||||
|
||||
## Safe write operations
|
||||
|
||||
### Issue comments and labels
|
||||
|
||||
```bash
|
||||
gh issue comment 42 --body "Looks good, marking as reviewed."
|
||||
gh issue edit 42 --add-label "reviewed"
|
||||
gh issue edit 42 --remove-label "needs-triage"
|
||||
gh issue edit 42 --add-assignee "username"
|
||||
```
|
||||
|
||||
### PR comments and reviews
|
||||
|
||||
```bash
|
||||
gh pr comment 99 --body "LGTM, one minor suggestion below."
|
||||
gh pr review 99 --approve --body "Approved."
|
||||
gh pr review 99 --request-changes --body "Please fix the failing test."
|
||||
gh pr review 99 --comment --body "Looks good overall."
|
||||
```
|
||||
|
||||
## NOT allowed
|
||||
|
||||
Do NOT perform these operations — they are destructive or require human decision:
|
||||
|
||||
- `gh pr merge` / `gh pr close` / `gh pr reopen`
|
||||
- `gh issue create` / `gh issue close` / `gh issue reopen`
|
||||
- `gh release create` / `gh release delete`
|
||||
- `gh repo delete` / `gh repo rename`
|
||||
- `git push --force` / `git push --force-with-lease`
|
||||
- Any `gh api -X DELETE` call
|
||||
- Any `gh api -X PUT/PATCH` on branch protection rules
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `--json` for structured output: `gh issue list --json number,title,labels`
|
||||
- Combine with `jq` for filtering: `gh pr list --json number,title,checks --jq '.[] | select(.checks | length > 0)'`
|
||||
- Rate limits: GitHub allows 5,000 requests/hour with token auth — plenty for normal use
|
||||
- For large lists, use `--limit`: `gh issue list --limit 50`
|
||||
- To get the repo owner/name: `gh repo view --json nameWithOwner --jq .nameWithOwner`
|
||||
@@ -1,17 +0,0 @@
|
||||
skill: github
|
||||
version: 1.0.0
|
||||
description: "GitHub CLI skill for container agents"
|
||||
core_version: 0.1.0
|
||||
adds:
|
||||
- container/skills/agent-github/SKILL.md
|
||||
modifies:
|
||||
- container/Dockerfile
|
||||
- src/container-runner.ts
|
||||
structured:
|
||||
npm_dependencies: {}
|
||||
env_additions:
|
||||
- GITHUB_TOKEN
|
||||
- GH_REPO
|
||||
conflicts: []
|
||||
depends: []
|
||||
test: "npm run build"
|
||||
@@ -1,77 +0,0 @@
|
||||
# NanoClaw Agent Container
|
||||
# Runs Claude Agent SDK in isolated Linux VM with browser automation
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
# Install system dependencies for Chromium
|
||||
RUN apt-get update && apt-get install -y \
|
||||
chromium \
|
||||
fonts-liberation \
|
||||
fonts-noto-cjk \
|
||||
fonts-noto-color-emoji \
|
||||
libgbm1 \
|
||||
libnss3 \
|
||||
libatk-bridge2.0-0 \
|
||||
libgtk-3-0 \
|
||||
libx11-xcb1 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxrandr2 \
|
||||
libasound2 \
|
||||
libpangocairo-1.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libxshmfence1 \
|
||||
curl \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install GitHub CLI
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update && apt-get install -y gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set Chromium path for agent-browser
|
||||
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
# Install agent-browser and claude-code globally
|
||||
RUN npm install -g agent-browser @anthropic-ai/claude-code
|
||||
|
||||
# Create app directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first for better caching
|
||||
COPY agent-runner/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY agent-runner/ ./
|
||||
|
||||
# Build TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# Create workspace directories
|
||||
RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
|
||||
|
||||
# Create entrypoint script
|
||||
# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it
|
||||
# Follow-up messages arrive via IPC files in /workspace/ipc/input/
|
||||
RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
|
||||
|
||||
# Set ownership to node user (non-root) for writable directories
|
||||
RUN chown -R node:node /workspace && chmod 777 /home/node
|
||||
|
||||
# Switch to non-root user (required for --dangerously-skip-permissions)
|
||||
USER node
|
||||
|
||||
# Set working directory to group workspace
|
||||
WORKDIR /workspace/group
|
||||
|
||||
# Entry point reads JSON from stdin, outputs JSON to stdout
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -1,30 +0,0 @@
|
||||
# Intent: container/Dockerfile modifications
|
||||
|
||||
## What changed
|
||||
Added GitHub CLI (`gh`) installation from GitHub's official apt repository, placed after the existing system dependencies block.
|
||||
|
||||
## Key sections
|
||||
|
||||
### gh CLI installation
|
||||
- Added after the `rm -rf /var/lib/apt/lists/*` line that closes the system dependencies block:
|
||||
```dockerfile
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update && apt-get install -y gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
- Uses the official GitHub apt repo with signed keyring
|
||||
- Cleans apt lists to minimize image size
|
||||
|
||||
## Invariants
|
||||
- All existing system dependencies (chromium, fonts, curl, git) are unchanged
|
||||
- The Chromium env vars below are unchanged
|
||||
- All subsequent steps (npm install, COPY, build, entrypoint) are unchanged
|
||||
|
||||
## Must-keep
|
||||
- The existing `apt-get install` block for system dependencies
|
||||
- The `AGENT_BROWSER_EXECUTABLE_PATH` and `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` env vars
|
||||
- The npm global install of `agent-browser` and `@anthropic-ai/claude-code`
|
||||
- The entire build, COPY, and entrypoint sequence
|
||||
@@ -1,704 +0,0 @@
|
||||
/**
|
||||
* Container Runner for NanoClaw
|
||||
* Spawns agent execution in containers and handles IPC
|
||||
*/
|
||||
import { ChildProcess, exec, spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
CONTAINER_IMAGE,
|
||||
CONTAINER_MAX_OUTPUT_SIZE,
|
||||
CONTAINER_TIMEOUT,
|
||||
DATA_DIR,
|
||||
GROUPS_DIR,
|
||||
IDLE_TIMEOUT,
|
||||
TIMEZONE,
|
||||
} from './config.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
CONTAINER_RUNTIME_BIN,
|
||||
readonlyMountArgs,
|
||||
stopContainer,
|
||||
} from './container-runtime.js';
|
||||
import { validateAdditionalMounts } from './mount-security.js';
|
||||
import { RegisteredGroup } from './types.js';
|
||||
|
||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||
|
||||
export interface ContainerInput {
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
groupFolder: string;
|
||||
chatJid: string;
|
||||
isMain: boolean;
|
||||
isScheduledTask?: boolean;
|
||||
assistantName?: string;
|
||||
secrets?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ContainerOutput {
|
||||
status: 'success' | 'error';
|
||||
result: string | null;
|
||||
newSessionId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface VolumeMount {
|
||||
hostPath: string;
|
||||
containerPath: string;
|
||||
readonly: boolean;
|
||||
}
|
||||
|
||||
function buildVolumeMounts(
|
||||
group: RegisteredGroup,
|
||||
isMain: boolean,
|
||||
): VolumeMount[] {
|
||||
const mounts: VolumeMount[] = [];
|
||||
const projectRoot = process.cwd();
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
|
||||
if (isMain) {
|
||||
// Main gets the project root read-only. Writable paths the agent needs
|
||||
// (group folder, IPC, .claude/) are mounted separately below.
|
||||
// Read-only prevents the agent from modifying host application code
|
||||
// (src/, dist/, package.json, etc.) which would bypass the sandbox
|
||||
// entirely on next restart.
|
||||
mounts.push({
|
||||
hostPath: projectRoot,
|
||||
containerPath: '/workspace/project',
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
// Shadow .env so the agent cannot read secrets from the mounted project root.
|
||||
// Secrets are passed via stdin instead (see readSecrets()).
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
mounts.push({
|
||||
hostPath: '/dev/null',
|
||||
containerPath: '/workspace/project/.env',
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Main also gets its group folder as the working directory
|
||||
mounts.push({
|
||||
hostPath: groupDir,
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false,
|
||||
});
|
||||
} else {
|
||||
// Other groups only get their own folder
|
||||
mounts.push({
|
||||
hostPath: groupDir,
|
||||
containerPath: '/workspace/group',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Global memory directory (read-only for non-main)
|
||||
// Only directory mounts are supported, not file mounts
|
||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||
if (fs.existsSync(globalDir)) {
|
||||
mounts.push({
|
||||
hostPath: globalDir,
|
||||
containerPath: '/workspace/global',
|
||||
readonly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Per-group Claude sessions directory (isolated from other groups)
|
||||
// Each group gets their own .claude/ to prevent cross-group session access
|
||||
const groupSessionsDir = path.join(
|
||||
DATA_DIR,
|
||||
'sessions',
|
||||
group.folder,
|
||||
'.claude',
|
||||
);
|
||||
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
||||
const settingsFile = path.join(groupSessionsDir, 'settings.json');
|
||||
if (!fs.existsSync(settingsFile)) {
|
||||
fs.writeFileSync(
|
||||
settingsFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
// Enable agent swarms (subagent orchestration)
|
||||
// https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
// Load CLAUDE.md from additional mounted directories
|
||||
// https://code.claude.com/docs/en/memory#load-memory-from-additional-directories
|
||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
|
||||
// Enable Claude's memory feature (persists user preferences between sessions)
|
||||
// https://code.claude.com/docs/en/memory#manage-auto-memory
|
||||
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
// Sync skills from container/skills/ into each group's .claude/skills/
|
||||
const skillsSrc = path.join(process.cwd(), 'container', 'skills');
|
||||
const skillsDst = path.join(groupSessionsDir, 'skills');
|
||||
if (fs.existsSync(skillsSrc)) {
|
||||
for (const skillDir of fs.readdirSync(skillsSrc)) {
|
||||
const srcDir = path.join(skillsSrc, skillDir);
|
||||
if (!fs.statSync(srcDir).isDirectory()) continue;
|
||||
const dstDir = path.join(skillsDst, skillDir);
|
||||
fs.cpSync(srcDir, dstDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
mounts.push({
|
||||
hostPath: groupSessionsDir,
|
||||
containerPath: '/home/node/.claude',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Per-group IPC namespace: each group gets its own IPC directory
|
||||
// This prevents cross-group privilege escalation via IPC
|
||||
const groupIpcDir = resolveGroupIpcPath(group.folder);
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
|
||||
fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
|
||||
mounts.push({
|
||||
hostPath: groupIpcDir,
|
||||
containerPath: '/workspace/ipc',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Copy agent-runner source into a per-group writable location so agents
|
||||
// can customize it (add tools, change behavior) without affecting other
|
||||
// groups. Recompiled on container startup via entrypoint.sh.
|
||||
const agentRunnerSrc = path.join(
|
||||
projectRoot,
|
||||
'container',
|
||||
'agent-runner',
|
||||
'src',
|
||||
);
|
||||
const groupAgentRunnerDir = path.join(
|
||||
DATA_DIR,
|
||||
'sessions',
|
||||
group.folder,
|
||||
'agent-runner-src',
|
||||
);
|
||||
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
|
||||
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
|
||||
}
|
||||
mounts.push({
|
||||
hostPath: groupAgentRunnerDir,
|
||||
containerPath: '/app/src',
|
||||
readonly: false,
|
||||
});
|
||||
|
||||
// Additional mounts validated against external allowlist (tamper-proof from containers)
|
||||
if (group.containerConfig?.additionalMounts) {
|
||||
const validatedMounts = validateAdditionalMounts(
|
||||
group.containerConfig.additionalMounts,
|
||||
group.name,
|
||||
isMain,
|
||||
);
|
||||
mounts.push(...validatedMounts);
|
||||
}
|
||||
|
||||
return mounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read allowed secrets from .env for passing to the container via stdin.
|
||||
* Secrets are never written to disk or mounted as files.
|
||||
*/
|
||||
function readSecrets(): Record<string, string> {
|
||||
return readEnvFile([
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'GITHUB_TOKEN',
|
||||
'GH_REPO',
|
||||
]);
|
||||
}
|
||||
|
||||
function buildContainerArgs(
|
||||
mounts: VolumeMount[],
|
||||
containerName: string,
|
||||
): string[] {
|
||||
const args: string[] = ['run', '-i', '--rm', '--name', containerName];
|
||||
|
||||
// Pass host timezone so container's local time matches the user's
|
||||
args.push('-e', `TZ=${TIMEZONE}`);
|
||||
|
||||
// Run as host user so bind-mounted files are accessible.
|
||||
// Skip when running as root (uid 0), as the container's node user (uid 1000),
|
||||
// or when getuid is unavailable (native Windows without WSL).
|
||||
const hostUid = process.getuid?.();
|
||||
const hostGid = process.getgid?.();
|
||||
if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
|
||||
args.push('--user', `${hostUid}:${hostGid}`);
|
||||
args.push('-e', 'HOME=/home/node');
|
||||
}
|
||||
|
||||
for (const mount of mounts) {
|
||||
if (mount.readonly) {
|
||||
args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
|
||||
} else {
|
||||
args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
args.push(CONTAINER_IMAGE);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function runContainerAgent(
|
||||
group: RegisteredGroup,
|
||||
input: ContainerInput,
|
||||
onProcess: (proc: ChildProcess, containerName: string) => void,
|
||||
onOutput?: (output: ContainerOutput) => Promise<void>,
|
||||
): Promise<ContainerOutput> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const groupDir = resolveGroupFolderPath(group.folder);
|
||||
fs.mkdirSync(groupDir, { recursive: true });
|
||||
|
||||
const mounts = buildVolumeMounts(group, input.isMain);
|
||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
const containerName = `nanoclaw-${safeName}-${Date.now()}`;
|
||||
const containerArgs = buildContainerArgs(mounts, containerName);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
group: group.name,
|
||||
containerName,
|
||||
mounts: mounts.map(
|
||||
(m) =>
|
||||
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
|
||||
),
|
||||
containerArgs: containerArgs.join(' '),
|
||||
},
|
||||
'Container mount configuration',
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
group: group.name,
|
||||
containerName,
|
||||
mountCount: mounts.length,
|
||||
isMain: input.isMain,
|
||||
},
|
||||
'Spawning container agent',
|
||||
);
|
||||
|
||||
const logsDir = path.join(groupDir, 'logs');
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
onProcess(container, containerName);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdoutTruncated = false;
|
||||
let stderrTruncated = false;
|
||||
|
||||
// Pass secrets via stdin (never written to disk or mounted as files)
|
||||
input.secrets = readSecrets();
|
||||
container.stdin.write(JSON.stringify(input));
|
||||
container.stdin.end();
|
||||
// Remove secrets from input so they don't appear in logs
|
||||
delete input.secrets;
|
||||
|
||||
// Streaming output: parse OUTPUT_START/END marker pairs as they arrive
|
||||
let parseBuffer = '';
|
||||
let newSessionId: string | undefined;
|
||||
let outputChain = Promise.resolve();
|
||||
|
||||
container.stdout.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
|
||||
// Always accumulate for logging
|
||||
if (!stdoutTruncated) {
|
||||
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
|
||||
if (chunk.length > remaining) {
|
||||
stdout += chunk.slice(0, remaining);
|
||||
stdoutTruncated = true;
|
||||
logger.warn(
|
||||
{ group: group.name, size: stdout.length },
|
||||
'Container stdout truncated due to size limit',
|
||||
);
|
||||
} else {
|
||||
stdout += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
// Stream-parse for output markers
|
||||
if (onOutput) {
|
||||
parseBuffer += chunk;
|
||||
let startIdx: number;
|
||||
while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
|
||||
const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
|
||||
if (endIdx === -1) break; // Incomplete pair, wait for more data
|
||||
|
||||
const jsonStr = parseBuffer
|
||||
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
|
||||
.trim();
|
||||
parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
|
||||
|
||||
try {
|
||||
const parsed: ContainerOutput = JSON.parse(jsonStr);
|
||||
if (parsed.newSessionId) {
|
||||
newSessionId = parsed.newSessionId;
|
||||
}
|
||||
hadStreamingOutput = true;
|
||||
// Activity detected — reset the hard timeout
|
||||
resetTimeout();
|
||||
// Call onOutput for all markers (including null results)
|
||||
// so idle timers start even for "silent" query completions.
|
||||
outputChain = outputChain.then(() => onOutput(parsed));
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ group: group.name, error: err },
|
||||
'Failed to parse streamed output chunk',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
container.stderr.on('data', (data) => {
|
||||
const chunk = data.toString();
|
||||
const lines = chunk.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line) logger.debug({ container: group.folder }, line);
|
||||
}
|
||||
// Don't reset timeout on stderr — SDK writes debug logs continuously.
|
||||
// Timeout only resets on actual output (OUTPUT_MARKER in stdout).
|
||||
if (stderrTruncated) return;
|
||||
const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
|
||||
if (chunk.length > remaining) {
|
||||
stderr += chunk.slice(0, remaining);
|
||||
stderrTruncated = true;
|
||||
logger.warn(
|
||||
{ group: group.name, size: stderr.length },
|
||||
'Container stderr truncated due to size limit',
|
||||
);
|
||||
} else {
|
||||
stderr += chunk;
|
||||
}
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
let hadStreamingOutput = false;
|
||||
const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
|
||||
// Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
|
||||
// graceful _close sentinel has time to trigger before the hard kill fires.
|
||||
const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
|
||||
|
||||
const killOnTimeout = () => {
|
||||
timedOut = true;
|
||||
logger.error(
|
||||
{ group: group.name, containerName },
|
||||
'Container timeout, stopping gracefully',
|
||||
);
|
||||
exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
|
||||
if (err) {
|
||||
logger.warn(
|
||||
{ group: group.name, containerName, err },
|
||||
'Graceful stop failed, force killing',
|
||||
);
|
||||
container.kill('SIGKILL');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let timeout = setTimeout(killOnTimeout, timeoutMs);
|
||||
|
||||
// Reset the timeout whenever there's activity (streaming output)
|
||||
const resetTimeout = () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(killOnTimeout, timeoutMs);
|
||||
};
|
||||
|
||||
container.on('close', (code) => {
|
||||
clearTimeout(timeout);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (timedOut) {
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const timeoutLog = path.join(logsDir, `container-${ts}.log`);
|
||||
fs.writeFileSync(
|
||||
timeoutLog,
|
||||
[
|
||||
`=== Container Run Log (TIMEOUT) ===`,
|
||||
`Timestamp: ${new Date().toISOString()}`,
|
||||
`Group: ${group.name}`,
|
||||
`Container: ${containerName}`,
|
||||
`Duration: ${duration}ms`,
|
||||
`Exit Code: ${code}`,
|
||||
`Had Streaming Output: ${hadStreamingOutput}`,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
// Timeout after output = idle cleanup, not failure.
|
||||
// The agent already sent its response; this is just the
|
||||
// container being reaped after the idle period expired.
|
||||
if (hadStreamingOutput) {
|
||||
logger.info(
|
||||
{ group: group.name, containerName, duration, code },
|
||||
'Container timed out after output (idle cleanup)',
|
||||
);
|
||||
outputChain.then(() => {
|
||||
resolve({
|
||||
status: 'success',
|
||||
result: null,
|
||||
newSessionId,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ group: group.name, containerName, duration, code },
|
||||
'Container timed out with no output',
|
||||
);
|
||||
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Container timed out after ${configTimeout}ms`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const logFile = path.join(logsDir, `container-${timestamp}.log`);
|
||||
const isVerbose =
|
||||
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
||||
|
||||
const logLines = [
|
||||
`=== Container Run Log ===`,
|
||||
`Timestamp: ${new Date().toISOString()}`,
|
||||
`Group: ${group.name}`,
|
||||
`IsMain: ${input.isMain}`,
|
||||
`Duration: ${duration}ms`,
|
||||
`Exit Code: ${code}`,
|
||||
`Stdout Truncated: ${stdoutTruncated}`,
|
||||
`Stderr Truncated: ${stderrTruncated}`,
|
||||
``,
|
||||
];
|
||||
|
||||
const isError = code !== 0;
|
||||
|
||||
if (isVerbose || isError) {
|
||||
logLines.push(
|
||||
`=== Input ===`,
|
||||
JSON.stringify(input, null, 2),
|
||||
``,
|
||||
`=== Container Args ===`,
|
||||
containerArgs.join(' '),
|
||||
``,
|
||||
`=== Mounts ===`,
|
||||
mounts
|
||||
.map(
|
||||
(m) =>
|
||||
`${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
|
||||
)
|
||||
.join('\n'),
|
||||
``,
|
||||
`=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
|
||||
stderr,
|
||||
``,
|
||||
`=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
|
||||
stdout,
|
||||
);
|
||||
} else {
|
||||
logLines.push(
|
||||
`=== Input Summary ===`,
|
||||
`Prompt length: ${input.prompt.length} chars`,
|
||||
`Session ID: ${input.sessionId || 'new'}`,
|
||||
``,
|
||||
`=== Mounts ===`,
|
||||
mounts
|
||||
.map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`)
|
||||
.join('\n'),
|
||||
``,
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(logFile, logLines.join('\n'));
|
||||
logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
|
||||
|
||||
if (code !== 0) {
|
||||
logger.error(
|
||||
{
|
||||
group: group.name,
|
||||
code,
|
||||
duration,
|
||||
stderr,
|
||||
stdout,
|
||||
logFile,
|
||||
},
|
||||
'Container exited with error',
|
||||
);
|
||||
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Streaming mode: wait for output chain to settle, return completion marker
|
||||
if (onOutput) {
|
||||
outputChain.then(() => {
|
||||
logger.info(
|
||||
{ group: group.name, duration, newSessionId },
|
||||
'Container completed (streaming mode)',
|
||||
);
|
||||
resolve({
|
||||
status: 'success',
|
||||
result: null,
|
||||
newSessionId,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy mode: parse the last output marker pair from accumulated stdout
|
||||
try {
|
||||
// Extract JSON between sentinel markers for robust parsing
|
||||
const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
|
||||
const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
|
||||
|
||||
let jsonLine: string;
|
||||
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||
jsonLine = stdout
|
||||
.slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
|
||||
.trim();
|
||||
} else {
|
||||
// Fallback: last non-empty line (backwards compatibility)
|
||||
const lines = stdout.trim().split('\n');
|
||||
jsonLine = lines[lines.length - 1];
|
||||
}
|
||||
|
||||
const output: ContainerOutput = JSON.parse(jsonLine);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
group: group.name,
|
||||
duration,
|
||||
status: output.status,
|
||||
hasResult: !!output.result,
|
||||
},
|
||||
'Container completed',
|
||||
);
|
||||
|
||||
resolve(output);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{
|
||||
group: group.name,
|
||||
stdout,
|
||||
stderr,
|
||||
error: err,
|
||||
},
|
||||
'Failed to parse container output',
|
||||
);
|
||||
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
container.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
logger.error(
|
||||
{ group: group.name, containerName, error: err },
|
||||
'Container spawn error',
|
||||
);
|
||||
resolve({
|
||||
status: 'error',
|
||||
result: null,
|
||||
error: `Container spawn error: ${err.message}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function writeTasksSnapshot(
|
||||
groupFolder: string,
|
||||
isMain: boolean,
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
groupFolder: string;
|
||||
prompt: string;
|
||||
schedule_type: string;
|
||||
schedule_value: string;
|
||||
status: string;
|
||||
next_run: string | null;
|
||||
}>,
|
||||
): void {
|
||||
// Write filtered tasks to the group's IPC directory
|
||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||
|
||||
// Main sees all tasks, others only see their own
|
||||
const filteredTasks = isMain
|
||||
? tasks
|
||||
: tasks.filter((t) => t.groupFolder === groupFolder);
|
||||
|
||||
const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
|
||||
fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
|
||||
}
|
||||
|
||||
export interface AvailableGroup {
|
||||
jid: string;
|
||||
name: string;
|
||||
lastActivity: string;
|
||||
isRegistered: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write available groups snapshot for the container to read.
|
||||
* Only main group can see all available groups (for activation).
|
||||
* Non-main groups only see their own registration status.
|
||||
*/
|
||||
export function writeGroupsSnapshot(
|
||||
groupFolder: string,
|
||||
isMain: boolean,
|
||||
groups: AvailableGroup[],
|
||||
registeredJids: Set<string>,
|
||||
): void {
|
||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||
|
||||
// Main sees all groups; others see nothing (they can't activate groups)
|
||||
const visibleGroups = isMain ? groups : [];
|
||||
|
||||
const groupsFile = path.join(groupIpcDir, 'available_groups.json');
|
||||
fs.writeFileSync(
|
||||
groupsFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
groups: visibleGroups,
|
||||
lastSync: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
# Intent: src/container-runner.ts modifications
|
||||
|
||||
## What changed
|
||||
Added `GITHUB_TOKEN` and `GH_REPO` to the `readSecrets()` allowlist so they are passed to the container via stdin JSON.
|
||||
|
||||
## Key sections
|
||||
|
||||
### readSecrets()
|
||||
- Added two keys to the array passed to `readEnvFile()`:
|
||||
```typescript
|
||||
function readSecrets(): Record<string, string> {
|
||||
return readEnvFile([
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'GITHUB_TOKEN',
|
||||
'GH_REPO',
|
||||
]);
|
||||
}
|
||||
```
|
||||
- These values flow via stdin -> `containerInput.secrets` -> `sdkEnv` -> available to Bash
|
||||
- `GITHUB_TOKEN` and `GH_REPO` are NOT added to `SECRET_ENV_VARS` in agent-runner because `gh` CLI needs them visible in Bash commands
|
||||
|
||||
## Invariants
|
||||
- All existing secrets (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`) are unchanged
|
||||
- The stdin-based secret passing flow is unchanged
|
||||
- No changes to volume mounts, container args, or any other functions
|
||||
|
||||
## Must-keep
|
||||
- All existing volume mounts
|
||||
- The mount security model
|
||||
- Container lifecycle (spawn, timeout, output parsing)
|
||||
- The `buildContainerArgs`, `runContainerAgent`, and all other functions
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
formatOutbound,
|
||||
stripInternalTags,
|
||||
} from './router.js';
|
||||
import { parseTextStyles, parseSignalStyles } from './text-styles.js';
|
||||
import { NewMessage } from './types.js';
|
||||
|
||||
function makeMsg(overrides: Partial<NewMessage> = {}): NewMessage {
|
||||
@@ -292,3 +293,259 @@ describe('trigger gating (requiresTrigger interaction)', () => {
|
||||
expect(shouldProcess(false, false, undefined, msgs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// --- parseTextStyles ---
|
||||
|
||||
describe('parseTextStyles — passthrough channels', () => {
|
||||
it('passes text through unchanged on discord', () => {
|
||||
const md = '**bold** and *italic* and [link](https://example.com)';
|
||||
expect(parseTextStyles(md, 'discord')).toBe(md);
|
||||
});
|
||||
|
||||
it('passes text through unchanged on signal (signal uses parseSignalStyles)', () => {
|
||||
const md = '**bold** and *italic* and [link](https://example.com)';
|
||||
expect(parseTextStyles(md, 'signal')).toBe(md);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — bold', () => {
|
||||
it('converts **bold** to *bold* on whatsapp', () => {
|
||||
expect(parseTextStyles('**hello**', 'whatsapp')).toBe('*hello*');
|
||||
});
|
||||
|
||||
it('converts **bold** to *bold* on telegram', () => {
|
||||
expect(parseTextStyles('say **this** now', 'telegram')).toBe(
|
||||
'say *this* now',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts **bold** to *bold* on slack', () => {
|
||||
expect(parseTextStyles('**hello**', 'slack')).toBe('*hello*');
|
||||
});
|
||||
|
||||
it('does not convert a lone * as bold', () => {
|
||||
expect(parseTextStyles('a * b * c', 'whatsapp')).toBe('a * b * c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — italic', () => {
|
||||
it('converts *italic* to _italic_ on whatsapp', () => {
|
||||
expect(parseTextStyles('say *this* now', 'whatsapp')).toBe(
|
||||
'say _this_ now',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts *italic* to _italic_ on telegram', () => {
|
||||
expect(parseTextStyles('*italic*', 'telegram')).toBe('_italic_');
|
||||
});
|
||||
|
||||
it('bold-before-italic: **bold** *italic* → *bold* _italic_', () => {
|
||||
expect(parseTextStyles('**bold** *italic*', 'whatsapp')).toBe(
|
||||
'*bold* _italic_',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — headings', () => {
|
||||
it('converts # heading on whatsapp', () => {
|
||||
expect(parseTextStyles('# Top', 'whatsapp')).toBe('*Top*');
|
||||
});
|
||||
|
||||
it('converts ## heading on telegram', () => {
|
||||
expect(parseTextStyles('## Hello World', 'telegram')).toBe('*Hello World*');
|
||||
});
|
||||
|
||||
it('converts ### heading on telegram', () => {
|
||||
expect(parseTextStyles('### Section', 'telegram')).toBe('*Section*');
|
||||
});
|
||||
|
||||
it('only converts headings at line start', () => {
|
||||
const input = 'not a ## heading in middle';
|
||||
expect(parseTextStyles(input, 'whatsapp')).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — links', () => {
|
||||
it('converts [text](url) to text (url) on whatsapp', () => {
|
||||
expect(parseTextStyles('[Link](https://example.com)', 'whatsapp')).toBe(
|
||||
'Link (https://example.com)',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts [text](url) to text (url) on telegram', () => {
|
||||
expect(parseTextStyles('[Link](https://example.com)', 'telegram')).toBe(
|
||||
'Link (https://example.com)',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts [text](url) to <url|text> on slack', () => {
|
||||
expect(parseTextStyles('[Click here](https://example.com)', 'slack')).toBe(
|
||||
'<https://example.com|Click here>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — horizontal rules', () => {
|
||||
it('strips --- on telegram', () => {
|
||||
expect(parseTextStyles('above\n---\nbelow', 'telegram')).toBe(
|
||||
'above\n\nbelow',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips *** on whatsapp', () => {
|
||||
expect(parseTextStyles('above\n***\nbelow', 'whatsapp')).toBe(
|
||||
'above\n\nbelow',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTextStyles — code block protection', () => {
|
||||
it('does not transform **bold** inside fenced code block', () => {
|
||||
const input = '```\n**not bold**\n```';
|
||||
expect(parseTextStyles(input, 'whatsapp')).toBe(input);
|
||||
});
|
||||
|
||||
it('does not transform *italic* inside inline code', () => {
|
||||
const input = 'use `*star*` literally';
|
||||
expect(parseTextStyles(input, 'telegram')).toBe(input);
|
||||
});
|
||||
|
||||
it('transforms text outside code blocks but not inside', () => {
|
||||
const input = '**bold** and `*code*` and *italic*';
|
||||
expect(parseTextStyles(input, 'whatsapp')).toBe(
|
||||
'*bold* and `*code*` and _italic_',
|
||||
);
|
||||
});
|
||||
|
||||
it('transforms text outside fenced block but not inside', () => {
|
||||
const input = '**bold**\n```\n**raw**\n```\n*italic*';
|
||||
expect(parseTextStyles(input, 'telegram')).toBe(
|
||||
'*bold*\n```\n**raw**\n```\n_italic_',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- parseSignalStyles ---
|
||||
|
||||
describe('parseSignalStyles — basic styles', () => {
|
||||
it('extracts BOLD from **text**', () => {
|
||||
const { text, textStyle } = parseSignalStyles('**hello**');
|
||||
expect(text).toBe('hello');
|
||||
expect(textStyle).toEqual([{ style: 'BOLD', start: 0, length: 5 }]);
|
||||
});
|
||||
|
||||
it('extracts ITALIC from *text*', () => {
|
||||
const { text, textStyle } = parseSignalStyles('*hello*');
|
||||
expect(text).toBe('hello');
|
||||
expect(textStyle).toEqual([{ style: 'ITALIC', start: 0, length: 5 }]);
|
||||
});
|
||||
|
||||
it('extracts ITALIC from _text_', () => {
|
||||
const { text, textStyle } = parseSignalStyles('_hello_');
|
||||
expect(text).toBe('hello');
|
||||
expect(textStyle).toEqual([{ style: 'ITALIC', start: 0, length: 5 }]);
|
||||
});
|
||||
|
||||
it('extracts STRIKETHROUGH from ~~text~~', () => {
|
||||
const { text, textStyle } = parseSignalStyles('~~hello~~');
|
||||
expect(text).toBe('hello');
|
||||
expect(textStyle).toEqual([
|
||||
{ style: 'STRIKETHROUGH', start: 0, length: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts MONOSPACE from `inline code`', () => {
|
||||
const { text, textStyle } = parseSignalStyles('`code`');
|
||||
expect(text).toBe('code');
|
||||
expect(textStyle).toEqual([{ style: 'MONOSPACE', start: 0, length: 4 }]);
|
||||
});
|
||||
|
||||
it('extracts BOLD from ## heading and strips marker', () => {
|
||||
const { text, textStyle } = parseSignalStyles('## Hello World');
|
||||
expect(text).toBe('Hello World');
|
||||
expect(textStyle).toEqual([{ style: 'BOLD', start: 0, length: 11 }]);
|
||||
});
|
||||
|
||||
it('no styles for plain text', () => {
|
||||
const { text, textStyle } = parseSignalStyles('just plain text');
|
||||
expect(text).toBe('just plain text');
|
||||
expect(textStyle).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSignalStyles — mixed content', () => {
|
||||
it('correctly offsets styles in mixed text', () => {
|
||||
const { text, textStyle } = parseSignalStyles('say **hi** now');
|
||||
expect(text).toBe('say hi now');
|
||||
expect(textStyle).toEqual([{ style: 'BOLD', start: 4, length: 2 }]);
|
||||
});
|
||||
|
||||
it('handles multiple styles with correct offsets', () => {
|
||||
const { text, textStyle } = parseSignalStyles('**bold** and *italic*');
|
||||
expect(text).toBe('bold and italic');
|
||||
expect(textStyle[0]).toEqual({ style: 'BOLD', start: 0, length: 4 });
|
||||
expect(textStyle[1]).toEqual({ style: 'ITALIC', start: 9, length: 6 });
|
||||
});
|
||||
|
||||
it('strips link markers, no style applied', () => {
|
||||
const { text, textStyle } = parseSignalStyles(
|
||||
'[Click here](https://example.com)',
|
||||
);
|
||||
expect(text).toBe('Click here (https://example.com)');
|
||||
expect(textStyle).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('strips horizontal rules', () => {
|
||||
const { text, textStyle } = parseSignalStyles('above\n---\nbelow');
|
||||
expect(text).toBe('above\nbelow');
|
||||
expect(textStyle).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSignalStyles — code block protection', () => {
|
||||
it('protects fenced code block content with MONOSPACE', () => {
|
||||
const input = '```\n**not bold**\n```';
|
||||
const { text, textStyle } = parseSignalStyles(input);
|
||||
expect(text).toBe('**not bold**');
|
||||
expect(textStyle).toEqual([{ style: 'MONOSPACE', start: 0, length: 12 }]);
|
||||
});
|
||||
|
||||
it('styles outside block are still processed', () => {
|
||||
const input = '**bold**\n```\nraw code\n```';
|
||||
const { text, textStyle } = parseSignalStyles(input);
|
||||
expect(text).toContain('bold');
|
||||
expect(text).toContain('raw code');
|
||||
const boldStyle = textStyle.find((s) => s.style === 'BOLD');
|
||||
const codeStyle = textStyle.find((s) => s.style === 'MONOSPACE');
|
||||
expect(boldStyle).toBeDefined();
|
||||
expect(codeStyle).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSignalStyles — snake_case guard', () => {
|
||||
it('does not italicise underscores in snake_case', () => {
|
||||
const { text, textStyle } = parseSignalStyles('use snake_case_here');
|
||||
expect(text).toBe('use snake_case_here');
|
||||
expect(textStyle).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatOutbound — channel-aware', () => {
|
||||
it('applies parseTextStyles when channel is provided', () => {
|
||||
expect(formatOutbound('**bold**', 'whatsapp')).toBe('*bold*');
|
||||
});
|
||||
|
||||
it('returns plain stripped text when no channel provided', () => {
|
||||
expect(formatOutbound('**bold**')).toBe('**bold**');
|
||||
});
|
||||
|
||||
it('strips internal tags then applies channel formatting', () => {
|
||||
expect(
|
||||
formatOutbound('<internal>thinking</internal>**done**', 'telegram'),
|
||||
).toBe('*done*');
|
||||
});
|
||||
|
||||
it('signal channel is passthrough — raw markdown preserved for parseSignalStyles', () => {
|
||||
expect(formatOutbound('**bold**', 'signal')).toBe('**bold**');
|
||||
});
|
||||
});
|
||||
|
||||
+5
-2
@@ -49,6 +49,7 @@ import { GroupQueue } from './group-queue.js';
|
||||
import { resolveGroupFolderPath } from './group-folder.js';
|
||||
import { startIpcWatcher } from './ipc.js';
|
||||
import { findChannel, formatMessages, formatOutbound } from './router.js';
|
||||
import { ChannelType } from './text-styles.js';
|
||||
import {
|
||||
restoreRemoteControl,
|
||||
startRemoteControl,
|
||||
@@ -686,14 +687,16 @@ async function main(): Promise<void> {
|
||||
logger.warn({ jid }, 'No channel owns JID, cannot send message');
|
||||
return;
|
||||
}
|
||||
const text = formatOutbound(rawText);
|
||||
const text = formatOutbound(rawText, channel.name as ChannelType);
|
||||
if (text) await channel.sendMessage(jid, text);
|
||||
},
|
||||
});
|
||||
startIpcWatcher({
|
||||
sendMessage: (jid, text) => {
|
||||
sendMessage: (jid, rawText) => {
|
||||
const channel = findChannel(channels, jid);
|
||||
if (!channel) throw new Error(`No channel for JID: ${jid}`);
|
||||
const text = formatOutbound(rawText, channel.name as ChannelType);
|
||||
if (!text) return Promise.resolve();
|
||||
return channel.sendMessage(jid, text);
|
||||
},
|
||||
registeredGroups: () => registeredGroups,
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
import { Channel, NewMessage } from './types.js';
|
||||
import { formatLocalTime } from './timezone.js';
|
||||
import { parseTextStyles, ChannelType } from './text-styles.js';
|
||||
|
||||
export function escapeXml(s: string): string {
|
||||
if (!s) return '';
|
||||
@@ -28,10 +29,10 @@ export function stripInternalTags(text: string): string {
|
||||
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
}
|
||||
|
||||
export function formatOutbound(rawText: string): string {
|
||||
export function formatOutbound(rawText: string, channel?: ChannelType): string {
|
||||
const text = stripInternalTags(rawText);
|
||||
if (!text) return '';
|
||||
return text;
|
||||
return channel ? parseTextStyles(text, channel) : text;
|
||||
}
|
||||
|
||||
export function routeOutbound(
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* parseTextStyles — convert Claude's Markdown output to channel-native formatting.
|
||||
*
|
||||
* Claude outputs standard Markdown. Each channel has its own text style syntax:
|
||||
* - Signal: passthrough (SignalChannel handles rich text styles natively
|
||||
* via the signal-cli JSON-RPC textStyle param — see parseSignalStyles)
|
||||
* - WhatsApp / Telegram: *bold*, _italic_, no headings, plain links
|
||||
* - Slack: *bold*, _italic_, <url|text> links
|
||||
* - Discord: passthrough (already Markdown)
|
||||
*
|
||||
* Code blocks (fenced and inline) are NEVER transformed by marker substitution.
|
||||
*/
|
||||
|
||||
export type ChannelType =
|
||||
| 'signal'
|
||||
| 'whatsapp'
|
||||
| 'telegram'
|
||||
| 'slack'
|
||||
| 'discord';
|
||||
|
||||
/** Transform Markdown text for the target channel's native format. */
|
||||
export function parseTextStyles(text: string, channel: ChannelType): string {
|
||||
if (!text) return text;
|
||||
|
||||
// Discord and Signal are passthrough — no marker substitution.
|
||||
// Discord is already Markdown; Signal uses parseSignalStyles() for rich text.
|
||||
if (channel === 'discord' || channel === 'signal') return text;
|
||||
|
||||
// Split into protected (code) and unprotected regions, transform only the latter.
|
||||
const segments = splitProtectedRegions(text);
|
||||
return segments
|
||||
.map(({ content, protected: isProtected }) =>
|
||||
isProtected ? content : transformSegment(content, channel),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal rich-text formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SignalTextStyle {
|
||||
/** One of Signal's supported text styles. */
|
||||
style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER';
|
||||
/** Start position in the final message string, in UTF-16 code units. */
|
||||
start: number;
|
||||
/** Length of the styled range, in UTF-16 code units. */
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Claude's Markdown into a plain string + Signal textStyle ranges.
|
||||
*
|
||||
* The returned `text` has all markdown markers stripped. The `textStyle`
|
||||
* array uses UTF-16 code-unit offsets (JavaScript's native string indexing),
|
||||
* matching what signal-cli's JSON-RPC `send.textStyle` param expects.
|
||||
*
|
||||
* Supported patterns:
|
||||
* **bold** → BOLD
|
||||
* *italic* → ITALIC
|
||||
* _italic_ → ITALIC
|
||||
* ~~strike~~ → STRIKETHROUGH
|
||||
* `inline code` → MONOSPACE
|
||||
* ```code block``` → MONOSPACE
|
||||
* ## Heading → BOLD (markers stripped)
|
||||
* [text](url) → "text (url)" (no style)
|
||||
* --- → removed
|
||||
*/
|
||||
export function parseSignalStyles(rawText: string): {
|
||||
text: string;
|
||||
textStyle: SignalTextStyle[];
|
||||
} {
|
||||
const textStyle: SignalTextStyle[] = [];
|
||||
let out = '';
|
||||
let i = 0;
|
||||
const s = rawText;
|
||||
const n = s.length;
|
||||
|
||||
function addStyle(
|
||||
style: SignalTextStyle['style'],
|
||||
startOut: number,
|
||||
endOut: number,
|
||||
): void {
|
||||
const length = endOut - startOut;
|
||||
if (length > 0) textStyle.push({ style, start: startOut, length });
|
||||
}
|
||||
|
||||
while (i < n) {
|
||||
// ── Fenced code block ```[lang]\n...\n``` ──────────────────────────
|
||||
if (s[i] === '`' && s[i + 1] === '`' && s[i + 2] === '`') {
|
||||
const langNl = s.indexOf('\n', i + 3);
|
||||
if (langNl !== -1) {
|
||||
// Find closing ``` on its own line
|
||||
const closeAt = s.indexOf('\n```', langNl);
|
||||
if (closeAt !== -1) {
|
||||
const content = s.slice(langNl + 1, closeAt);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('MONOSPACE', startOut, out.length);
|
||||
// Advance past \n``` + optional trailing newline
|
||||
const afterClose = s.indexOf('\n', closeAt + 4);
|
||||
i = afterClose !== -1 ? afterClose + 1 : n;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Malformed fence — copy literally
|
||||
out += s[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Inline code `text` ────────────────────────────────────────────
|
||||
if (s[i] === '`') {
|
||||
const end = s.indexOf('`', i + 1);
|
||||
const nl = s.indexOf('\n', i + 1);
|
||||
if (end !== -1 && (nl === -1 || end < nl)) {
|
||||
const content = s.slice(i + 1, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('MONOSPACE', startOut, out.length);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bold **text** ─────────────────────────────────────────────────
|
||||
if (s[i] === '*' && s[i + 1] === '*' && s[i + 2] && s[i + 2] !== ' ') {
|
||||
const end = s.indexOf('**', i + 2);
|
||||
if (end !== -1 && s[end - 1] !== ' ') {
|
||||
const content = s.slice(i + 2, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('BOLD', startOut, out.length);
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strikethrough ~~text~~ ────────────────────────────────────────
|
||||
if (s[i] === '~' && s[i + 1] === '~' && s[i + 2] && s[i + 2] !== ' ') {
|
||||
const end = s.indexOf('~~', i + 2);
|
||||
if (end !== -1) {
|
||||
const content = s.slice(i + 2, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('STRIKETHROUGH', startOut, out.length);
|
||||
i = end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Italic *text* (single star, not part of **) ─────────────────
|
||||
if (
|
||||
s[i] === '*' &&
|
||||
s[i + 1] !== '*' &&
|
||||
s[i + 1] !== ' ' &&
|
||||
s[i + 1] !== undefined
|
||||
) {
|
||||
const end = findClosingStar(s, i + 1);
|
||||
if (end !== -1) {
|
||||
const content = s.slice(i + 1, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('ITALIC', startOut, out.length);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Italic _text_ (only at word boundaries) ──────────────────────
|
||||
if (s[i] === '_' && s[i + 1] !== '_' && s[i + 1] !== ' ' && s[i + 1]) {
|
||||
// Guard against snake_case: only treat as italic when preceded by a
|
||||
// non-word character (or start of string).
|
||||
const prevChar = i > 0 ? s[i - 1] : '';
|
||||
if (!/\w/.test(prevChar)) {
|
||||
const end = findClosingUnderscore(s, i + 1);
|
||||
if (end !== -1) {
|
||||
const content = s.slice(i + 1, end);
|
||||
const startOut = out.length;
|
||||
out += content;
|
||||
addStyle('ITALIC', startOut, out.length);
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ATX Heading ## text → text (as BOLD) ─────────────────────────
|
||||
if ((i === 0 || s[i - 1] === '\n') && s[i] === '#') {
|
||||
let j = i;
|
||||
while (j < n && s[j] === '#') j++;
|
||||
if (j < n && s[j] === ' ') {
|
||||
const lineEnd = s.indexOf('\n', j + 1);
|
||||
const headingText =
|
||||
lineEnd !== -1 ? s.slice(j + 1, lineEnd) : s.slice(j + 1);
|
||||
const startOut = out.length;
|
||||
out += headingText;
|
||||
addStyle('BOLD', startOut, out.length);
|
||||
if (lineEnd !== -1) {
|
||||
out += '\n';
|
||||
i = lineEnd + 1;
|
||||
} else i = n;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Links [text](url) → text (url) ───────────────────────────────
|
||||
if (s[i] === '[') {
|
||||
const closeBracket = s.indexOf(']', i + 1);
|
||||
if (closeBracket !== -1 && s[closeBracket + 1] === '(') {
|
||||
const closeParen = s.indexOf(')', closeBracket + 2);
|
||||
if (closeParen !== -1) {
|
||||
const linkText = s.slice(i + 1, closeBracket);
|
||||
const url = s.slice(closeBracket + 2, closeParen);
|
||||
out += `${linkText} (${url})`;
|
||||
i = closeParen + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Horizontal rule --- / *** / ___ ──────────────────────────────
|
||||
if (i === 0 || s[i - 1] === '\n') {
|
||||
const hrMatch = /^(-{3,}|\*{3,}|_{3,}) *(\n|$)/.exec(s.slice(i));
|
||||
if (hrMatch) {
|
||||
i += hrMatch[0].length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Default: copy character, preserving surrogate pairs ───────────
|
||||
const code = s.charCodeAt(i);
|
||||
if (code >= 0xd800 && code <= 0xdbff && i + 1 < n) {
|
||||
out += s[i] + s[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
out += s[i];
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return { text: out, textStyle };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for parseSignalStyles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Find the position of a closing single `*` that isn't part of `**`. */
|
||||
function findClosingStar(s: string, from: number): number {
|
||||
for (let i = from; i < s.length; i++) {
|
||||
if (s[i] === '\n') return -1; // italics don't span lines
|
||||
if (s[i] === '*' && s[i + 1] !== '*' && s[i - 1] !== ' ') return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Find the closing `_` that isn't part of `__` and is at a word boundary. */
|
||||
function findClosingUnderscore(s: string, from: number): number {
|
||||
for (let i = from; i < s.length; i++) {
|
||||
if (s[i] === '\n') return -1;
|
||||
if (s[i] === '_' && s[i + 1] !== '_' && !/\w/.test(s[i + 1] ?? '')) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Marker-substitution helpers (WhatsApp / Telegram / Slack)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Segment {
|
||||
content: string;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into alternating unprotected/protected segments.
|
||||
* Protected = fenced code blocks (```...```) and inline code (`...`).
|
||||
*/
|
||||
function splitProtectedRegions(text: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
const CODE_PATTERN = /```[\s\S]*?```|`[^`\n]+`/g;
|
||||
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = CODE_PATTERN.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
content: text.slice(lastIndex, match.index),
|
||||
protected: false,
|
||||
});
|
||||
}
|
||||
segments.push({ content: match[0], protected: true });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ content: text.slice(lastIndex), protected: false });
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ content: text, protected: false }];
|
||||
}
|
||||
|
||||
/** Apply marker-substitution transformations to a non-code segment. */
|
||||
function transformSegment(text: string, channel: ChannelType): string {
|
||||
let t = text;
|
||||
|
||||
// Order matters: italic before bold.
|
||||
// The italic regex won't match **bold** (it requires the char after the opening *
|
||||
// to be a non-* non-space), so running italic first is safe. If we ran bold
|
||||
// first (**bold** → *bold*), the italic step would immediately re-convert *bold*
|
||||
// to _bold_, producing wrong output.
|
||||
|
||||
// 1. Italic: *text* → _text_ (whatsapp/telegram/slack use _)
|
||||
t = t.replace(/(?<!\*)\*(?=[^\s*])([^*\n]+?)(?<=[^\s*])\*(?!\*)/g, '_$1_');
|
||||
|
||||
// 2. Bold: **text** → *text* (whatsapp/telegram/slack use single *)
|
||||
t = t.replace(/\*\*(?=[^\s*])([^*]+?)(?<=[^\s*])\*\*/g, '*$1*');
|
||||
|
||||
// 3. Headings: ## Title → *Title* (any level, line-start only)
|
||||
t = t.replace(/^#{1,6}\s+(.+)$/gm, '*$1*');
|
||||
|
||||
// 4. Links
|
||||
if (channel === 'slack') {
|
||||
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>');
|
||||
} else {
|
||||
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)');
|
||||
}
|
||||
|
||||
// 5. Horizontal rules: strip them
|
||||
t = t.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '');
|
||||
|
||||
return t;
|
||||
}
|
||||
Reference in New Issue
Block a user