Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
1f85bd4
ci: add sync-build workflow
Mar 5, 2026
035ae3b
feat: custom Dockerfile for Lazycat NAS deployment
Mar 5, 2026
4e274aa
ci: use main branch instead of custom
Mar 5, 2026
bd2ef99
feat: add gogcli to image
Mar 5, 2026
4a1214b
chore: sync to upstream v0.3.22
actions-user Mar 5, 2026
e550aad
feat: install brew + gogcli as non-root, add PATH for npm-global
Mar 5, 2026
8189b99
chore: sync to upstream v0.3.23
actions-user Mar 5, 2026
9b64dbc
chore: sync to upstream v0.3.24
actions-user Mar 6, 2026
a4c0bba
docs: add Docker Hub README with auto-sync via GitHub Actions
Mar 6, 2026
b5d2bcc
feat: add jq to image
Mar 6, 2026
bf31aa5
docs: fix gogcli description — Google Workspace CLI, not GOG.com
Mar 6, 2026
3a5003b
fix: logo visibility in light theme
Mar 6, 2026
0b73375
fix: invert logo in message avatar for light theme
Mar 6, 2026
879eae8
fix: detect Claude Code CLI install and auth status in provider card
Mar 6, 2026
c7c8458
fix: use PAT_TOKEN for workflow push permission
Mar 7, 2026
6460e0d
feat: add Playwright + Chromium to Docker image
Mar 7, 2026
07b2e0b
fix: use force push in sync workflow to avoid stale info rejection
Mar 7, 2026
4eaa5a4
chore: sync to upstream v0.3.26
actions-user Mar 7, 2026
0510605
fix: add python -> python3 symlink for Browser Hand detection
Mar 7, 2026
26ec0ea
fix: default hourly cost quota should be unlimited (0.0), not $1.00
Mar 7, 2026
7c3ce06
ci: trigger Docker rebuild on every push to main
Mar 7, 2026
8570594
chore: sync to upstream v0.3.27
actions-user Mar 7, 2026
189bd07
chore: sync to upstream v0.3.34
Mar 9, 2026
7e4383c
fix: auto-skip conflicting commits during upstream rebase
Mar 9, 2026
2f7cc39
fix: replace Playwright with chromium package in Docker image
Mar 9, 2026
734f9df
fix: re-add --dangerously-skip-permissions to Claude Code driver
Mar 11, 2026
f5328e0
chore: sync to upstream v0.3.47
actions-user Mar 12, 2026
0d62faa
feat: add Qwen Code CLI as LLM provider + refactor build_args for tes…
Mar 12, 2026
ddb4a73
feat: add Qwen Code CLI to Docker image
Mar 12, 2026
2b3784b
fix: correct Qwen Code credentials path from ~/.qwen-code to ~/.qwen
Mar 12, 2026
3e4ba74
fix(images): fix Telegram image pipeline - dual bug in vision model r…
f-liva Mar 12, 2026
eb351f1
feat: add Telegram notifications to CI workflow
Mar 12, 2026
401b076
fix: skip vision model swap when current model already supports vision
f-liva Mar 12, 2026
08f59a7
fix: vision model swap respects config priority over current model
f-liva Mar 12, 2026
6d67a0c
fix: use --file instead of --files for Claude Code CLI
f-liva Mar 12, 2026
7de627a
fix: use @path syntax for local images instead of --file flag
f-liva Mar 12, 2026
3c9ad58
feat: add Telegram notification on build start
Mar 12, 2026
e860faf
chore: sync to upstream v0.4.0
actions-user Mar 13, 2026
f92c584
feat: multi-profile token rotation for Claude Code driver
Mar 13, 2026
6db5dd6
feat(whatsapp): propagate sender identity metadata to agent context
f-liva Mar 14, 2026
7282cee
fix(whatsapp-gateway): reply to group messages in group, not as priva…
f-liva Mar 14, 2026
88dd375
feat(whatsapp-gateway): add media message support (images, video, aud…
f-liva Mar 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .current-upstream-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v0.4.0
104 changes: 104 additions & 0 deletions .github/workflows/sync-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
name: Sync upstream & build custom image

on:
schedule:
- cron: '0 */6 * * *' # ogni 6 ore
workflow_dispatch: # trigger manuale
push:
branches: [main] # rebuild ad ogni push su main

jobs:
sync-and-build:
runs-on: ubuntu-latest
steps:
- name: Checkout fork
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PAT_TOKEN }}

- name: Notify Telegram (start)
run: |
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
-d parse_mode="Markdown" \
-d text="🚀 *OpenFang Build Started*%0A%0ATrigger: \`${{ github.event_name }}\`%0ACommit: \`${GITHUB_SHA::7}\`%0A[View run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})"

- name: Fetch upstream tags
if: github.event_name != 'push'
run: |
git remote add upstream https://github.com/RightNow-AI/openfang.git || true
git fetch upstream --tags

- name: Check for new release
id: check
if: github.event_name != 'push'
run: |
LATEST_TAG=$(git tag -l 'v*' --sort=-v:refname | head -1)
CURRENT=$(cat .current-upstream-version 2>/dev/null || echo "none")
echo "latest=$LATEST_TAG" >> "$GITHUB_OUTPUT"
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
if [ "$LATEST_TAG" != "$CURRENT" ]; then
echo "new_release=true" >> "$GITHUB_OUTPUT"
else
echo "new_release=false" >> "$GITHUB_OUTPUT"
fi

- name: Rebase on latest tag
if: github.event_name != 'push' && steps.check.outputs.new_release == 'true'
run: |
git config user.name "github-actions"
git config user.email "actions@github.com"
GIT_SEQUENCE_EDITOR=true git rebase ${{ steps.check.outputs.latest }} || while git rebase --skip 2>/dev/null; do :; done
echo "${{ steps.check.outputs.latest }}" > .current-upstream-version
git add .current-upstream-version
git commit -m "chore: sync to upstream ${{ steps.check.outputs.latest }}" || true
git push --force

- name: Set up Docker Buildx
if: github.event_name == 'push' || steps.check.outputs.new_release == 'true'
uses: docker/setup-buildx-action@v3

- name: Login to Docker Hub
if: github.event_name == 'push' || steps.check.outputs.new_release == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Build and push
if: github.event_name == 'push' || steps.check.outputs.new_release == 'true'
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
fliva/openfang:latest
fliva/openfang:${{ steps.check.outputs.latest || 'custom' }}
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Update Docker Hub description
if: github.event_name == 'push' || steps.check.outputs.new_release == 'true'
uses: peter-evans/dockerhub-description@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: fliva/openfang
readme-filepath: ./DOCKER_README.md

- name: Notify Telegram (success)
if: success() && (github.event_name == 'push' || steps.check.outputs.new_release == 'true')
run: |
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
-d parse_mode="Markdown" \
-d text="✅ *OpenFang Build OK*%0A%0ATag: \`${{ steps.check.outputs.latest || 'custom' }}\`%0ACommit: \`${GITHUB_SHA::7}\`%0A[View run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})"

- name: Notify Telegram (failure)
if: failure()
run: |
curl -s -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
-d parse_mode="Markdown" \
-d text="❌ *OpenFang Build FAILED*%0A%0ACommit: \`${GITHUB_SHA::7}\`%0A[View run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})"
42 changes: 42 additions & 0 deletions DOCKER_README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# OpenFang for Lazycat NAS

Custom [OpenFang](https://github.com/RightNow-AI/openfang) Docker image optimized for deployment on Lazycat LCMD Microserver.

**Automatically rebuilt on every new upstream release via GitHub Actions.**

## What's included

- **OpenFang Agent OS** — Rust-based autonomous AI agent daemon
- **Claude Code CLI** — Anthropic's CLI for Claude, as LLM provider
- **Node.js 22** — JavaScript runtime
- **Python 3** — Python runtime
- **Go** — via Homebrew
- **Homebrew** — package manager for additional tools
- **uv** — fast Python package manager
- **gh** — GitHub CLI
- **gog** — [Google Workspace CLI](https://gogcli.sh/) (Gmail, Calendar, Drive, Sheets, etc.)
- **ffmpeg** — multimedia processing
- **jq** — JSON processor
- **git, curl, wget** — standard utilities

## Non-root execution

The image uses `gosu` to drop root privileges to the `openfang` user at runtime. This is required because Claude Code's `--dangerously-skip-permissions` flag refuses to run as root.

The `openfang` user has passwordless `sudo` access, so it can still install system packages when needed.

## Usage

```bash
docker run -d \
-p 4200:4200 \
-v openfang-data:/data \
-v openfang-home:/home/openfang \
-e OPENFANG_HOME=/data \
fliva/openfang:latest
```

## Source

- **This fork**: [github.com/f-liva/openfang](https://github.com/f-liva/openfang)
- **Upstream**: [github.com/RightNow-AI/openfang](https://github.com/RightNow-AI/openfang)
29 changes: 27 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,36 @@ COPY packages ./packages
RUN cargo build --release --bin openfang

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y ca-certificates curl git ffmpeg python3 python3-pip chromium gosu sudo procps build-essential jq && rm -rf /var/lib/apt/lists/*
RUN ln -s /usr/bin/python3 /usr/bin/python
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
RUN (type -p wget >/dev/null || (apt-get update && apt-get install -y wget)) && \
mkdir -p -m 755 /etc/apt/keyrings && \
out=$(mktemp) && wget -qO "$out" https://cli.github.com/packages/githubcli-archive-keyring.gpg && \
cat "$out" | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null && \
chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \
apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
apt-get install -y nodejs && \
npm install -g @anthropic-ai/claude-code @qwen-code/qwen-code && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash openfang && echo "openfang ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/openfang
USER openfang
RUN NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
RUN eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && brew install steipete/tap/gogcli
USER root
RUN echo 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"' >> /home/openfang/.bashrc && \
echo 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"' >> /root/.bashrc && \
echo 'export PATH="/data/npm-global/bin:$PATH"' >> /home/openfang/.bashrc && \
echo 'export PATH="/data/npm-global/bin:$PATH"' >> /root/.bashrc
COPY --from=builder /build/target/release/openfang /usr/local/bin/
COPY --from=builder /build/agents /opt/openfang/agents
RUN mkdir -p /data && chown openfang:openfang /data
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 4200
VOLUME /data
ENV OPENFANG_HOME=/data
ENTRYPOINT ["openfang"]
ENTRYPOINT ["entrypoint.sh"]
CMD ["start"]
39 changes: 38 additions & 1 deletion crates/openfang-api/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,10 +358,45 @@ pub async fn send_message(
}
}

// Convert metadata from the gateway into a SenderContext for the kernel
let sender_context = req.metadata.as_ref().map(|meta| {
openfang_types::message::SenderContext {
channel: meta.get("channel").and_then(|v| v.as_str().map(String::from)),
sender_id: meta.get("sender").and_then(|v| v.as_str().map(String::from)),
sender_name: meta.get("sender_name").and_then(|v| v.as_str().map(String::from)),
}
});

// SECURITY: Check allowed_users for channel-based messages (WhatsApp, etc.)
// If allowed_users is empty, all senders are permitted (open mode).
if let Some(ref ctx) = sender_context {
if let Some(ref sender_id) = ctx.sender_id {
if let Some(ref channel) = ctx.channel {
let channels_config = state.channels_config.read().await;
let blocked = match channel.as_str() {
"whatsapp" => channels_config.whatsapp.as_ref().map_or(false, |wa| {
!wa.allowed_users.is_empty()
&& !wa.allowed_users.iter().any(|u| u == sender_id)
}),
_ => false,
};
if blocked {
tracing::warn!(
"Rejected message from unlisted {channel} user {sender_id}"
);
return (
StatusCode::FORBIDDEN,
Json(serde_json::json!({"error": "Sender not in allowed_users list"})),
);
}
}
}
}

let kernel_handle: Arc<dyn KernelHandle> = state.kernel.clone() as Arc<dyn KernelHandle>;
match state
.kernel
.send_message_with_handle(agent_id, &req.message, Some(kernel_handle))
.send_message_with_handle_and_blocks(agent_id, &req.message, Some(kernel_handle), None, sender_context)
.await
{
Ok(result) => {
Expand Down Expand Up @@ -7023,6 +7058,7 @@ pub async fn set_provider_key(
model: model_id,
api_key_env: env_var.clone(),
base_url: None,
vision_model: None,
};
let mut guard = state
.kernel
Expand Down Expand Up @@ -7186,6 +7222,7 @@ pub async fn test_provider(
Some(base_url)
},
skip_permissions: true,
profiles: vec![],
};

match openfang_runtime::drivers::create_driver(&driver_config) {
Expand Down
9 changes: 9 additions & 0 deletions crates/openfang-api/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ pub struct MessageRequest {
/// Optional file attachments (uploaded via /upload endpoint).
#[serde(default)]
pub attachments: Vec<AttachmentRef>,
/// Optional channel metadata (sender identity, channel type).
///
/// Used by external gateways (e.g. WhatsApp) to forward sender information
/// so the agent knows who is writing. Expected keys:
/// - `channel`: channel name (e.g. "whatsapp", "telegram")
/// - `sender`: platform-specific sender ID (e.g. phone number)
/// - `sender_name`: human-readable sender name
#[serde(default)]
pub metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
}

/// Response from sending a message.
Expand Down
5 changes: 5 additions & 0 deletions crates/openfang-api/static/css/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@
transition: opacity 0.2s, transform 0.2s;
}

[data-theme="light"] .sidebar-logo img,
[data-theme="light"] .message-avatar img {
filter: invert(1);
}

.sidebar-logo img:hover {
opacity: 1;
transform: scale(1.05);
Expand Down
Binary file modified crates/openfang-api/static/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 27 additions & 18 deletions crates/openfang-channels/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,26 +529,35 @@ async fn dispatch_message(
return;
}

// For images: download, base64 encode, and send as multimodal content blocks
// For images: build content blocks with the image URL for vision models.
// We pass the original URL rather than downloading + base64-encoding because
// many providers (DashScope/Qwen, OpenAI) prefer or require direct URLs.
if let ChannelContent::Image { ref url, ref caption } = message.content {
let blocks = download_image_to_blocks(url, caption.as_deref()).await;
if blocks.iter().any(|b| matches!(b, ContentBlock::Image { .. })) {
// We have actual image data — send as structured blocks for vision
dispatch_with_blocks(
blocks,
message,
handle,
router,
adapter,
ct_str,
thread_id,
output_format,
lifecycle_reactions,
)
.await;
return;
let mut blocks = Vec::new();
if let Some(cap) = caption {
if !cap.is_empty() {
blocks.push(ContentBlock::Text {
text: cap.clone(),
provider_metadata: None,
});
}
}
// Image download failed — fall through to text description below
blocks.push(ContentBlock::ImageUrl {
url: url.clone(),
});
dispatch_with_blocks(
blocks,
message,
handle,
router,
adapter,
ct_str,
thread_id,
output_format,
lifecycle_reactions,
)
.await;
return;
}

let text = match &message.content {
Expand Down
4 changes: 2 additions & 2 deletions crates/openfang-channels/src/whatsapp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,8 @@ impl WhatsAppAdapter {
}

/// Check if a phone number is allowed.
#[allow(dead_code)]
fn is_allowed(&self, phone: &str) -> bool {
/// Returns true if allowed_users is empty (open mode) or phone is in the list.
pub fn is_allowed(&self, phone: &str) -> bool {
self.allowed_users.is_empty() || self.allowed_users.iter().any(|u| u == phone)
}

Expand Down
Loading