Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ permissions:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "26"
PNPM_VERSION: "11.13.1"
PNPM_VERSION: "11.19.0"

concurrency:
group: ci-${{ github.ref }}
Expand Down Expand Up @@ -44,7 +44,7 @@ jobs:
CLICKCLACK_POSTGRES_TEST_DSN: postgres://clickclack:clickclack@127.0.0.1:5432/clickclack_test?sslmode=disable
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

Expand Down Expand Up @@ -86,7 +86,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

Expand Down Expand Up @@ -136,11 +136,11 @@ jobs:
- typescript
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-noble
image: mcr.microsoft.com/playwright:v1.62.1-noble
timeout-minutes: 20
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

Expand Down Expand Up @@ -179,7 +179,7 @@ jobs:
timeout-minutes: 20
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/crabbox-hydrate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ permissions:

env:
NODE_VERSION: "26"
PNPM_VERSION: "11.13.1"
PNPM_VERSION: "11.19.0"

jobs:
hydrate:
name: hydrate
runs-on: [self-hosted, crabbox, openclaw, clickclack, "${{ inputs.crabbox_runner_label }}"]
timeout-minutes: 120
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/desktop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ permissions:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "26"
PNPM_VERSION: "11.13.1"
PNPM_VERSION: "11.19.0"
CSC_IDENTITY_AUTO_DISCOVERY: "false"

concurrency:
Expand Down Expand Up @@ -54,7 +54,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/fakeco-aws.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ jobs:
FAKECO_DATA_KMS_KEY_ARN: ${{ vars.FAKECO_DATA_KMS_KEY_ARN }}
steps:
- name: Check out protected main
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
Expand Down Expand Up @@ -96,7 +96,7 @@ jobs:
node deploy/fakeco/aws/owner.mjs validate-profile

- name: Configure exact target-account role
uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2
uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3
with:
allowed-account-ids: ${{ vars.FAKECO_AWS_ACCOUNT_ID }}
aws-region: us-west-2
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ permissions:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "26"
PNPM_VERSION: "11.13.1"
PNPM_VERSION: "11.19.0"

jobs:
release:
Expand All @@ -28,7 +28,7 @@ jobs:
contents: write
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
Expand Down Expand Up @@ -98,7 +98,7 @@ jobs:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ inputs.tag_name || github.ref }}
Expand Down Expand Up @@ -166,7 +166,7 @@ jobs:
contents: write
steps:
- name: Check out trusted verifier
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.workflow_sha }}
Expand Down
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
# Changelog

## 0.2.2 - Unreleased
## 0.3.0 - Unreleased

- Added coherent conversation organization and attention tools: topic selection and filtered timelines plus per-channel all, mentions-only, and muted notification preferences across web, desktop, Pushover, API, and SDK surfaces. Thanks @PollyBot13 and @jjjhenriksen.

## 0.2.2 - 2026-08-01

- Updated Go, web, desktop, Cloudflare, CI action, package-manager, and container-base dependencies.
- Fixed bot topic endpoints accepting cross-workspace list and create operations by enforcing the token workspace scope. Thanks @ShiroKSH.
- Fixed right-aligned messages rendering Markdown lists and blockquotes outside the message bubble. Thanks @jjjhenriksen.
- Added optional human-readable channel display titles while preserving slug-based routing and uniqueness.
- Added agent-friendly CLI commands for adding and removing message reactions through the existing public APIs, with exact JSON output and terminal-safe human output. Thanks @PollyBot13.
- Removed the redundant "Open in ClickClack" link from embedded channel headers, leaving external navigation to the host's own control.
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
FROM node:26-alpine@sha256:e88a35be04478413b7c71c455cd9865de9b9360e1f43456be5951032d7ac1a66 AS web
FROM node:26-alpine@sha256:233761595746769ebfdb6090f44fc7cdf818ae0ce62d2b37e0367723b9823e36 AS web
ARG CLICKCLACK_WEB_VERSION=dev
ENV CLICKCLACK_WEB_VERSION=$CLICKCLACK_WEB_VERSION
WORKDIR /src
RUN npm install -g pnpm@11.13.1
RUN npm install -g pnpm@11.19.0
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/package.json
COPY packages/protocol/package.json packages/protocol/package.json
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile.cloudflare
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
FROM node:26-alpine@sha256:e88a35be04478413b7c71c455cd9865de9b9360e1f43456be5951032d7ac1a66 AS web
FROM node:26-alpine@sha256:233761595746769ebfdb6090f44fc7cdf818ae0ce62d2b37e0367723b9823e36 AS web
ARG CLICKCLACK_WEB_VERSION=cloudflare
ENV CLICKCLACK_WEB_VERSION=$CLICKCLACK_WEB_VERSION
WORKDIR /src
RUN npm install -g pnpm@11.13.1
RUN npm install -g pnpm@11.19.0
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/package.json
COPY packages/protocol/package.json packages/protocol/package.json
Expand Down
13 changes: 13 additions & 0 deletions apps/api/internal/httpapi/authz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ func TestHTTPUnauthorizedRoutes(t *testing.T) {
{http.MethodPost, "/api/messages/msg_missing/thread/replies", `{"body":"x"}`},
{http.MethodPost, "/api/messages/msg_missing/reactions", `{"emoji":"x"}`},
{http.MethodDelete, "/api/messages/msg_missing/reactions/x", ""},
{http.MethodGet, "/api/channels/chn_missing/notification-settings", ""},
{http.MethodPatch, "/api/channels/chn_missing/notification-settings", `{"preference":"muted"}`},
{http.MethodGet, "/api/realtime/events?workspace_id=wsp_missing", ""},
{http.MethodGet, "/api/realtime/ws?workspace_id=wsp_missing", ""},
{http.MethodGet, "/api/search?workspace_id=wsp_missing&q=x", ""},
Expand Down Expand Up @@ -214,12 +216,23 @@ func TestHTTPBotTokenWorkspaceIsolation(t *testing.T) {
expectStatusWithBearer(t, token.Token, http.MethodGet, server.URL+"/api/workspaces/"+otherWorkspace.ID, nil, http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodGet, server.URL+"/api/workspaces/"+otherWorkspace.ID+"/channels", nil, http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodPost, server.URL+"/api/workspaces/"+otherWorkspace.ID+"/channels", strings.NewReader(`{"name":"hidden"}`), http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodGet, server.URL+"/api/workspaces/"+otherWorkspace.ID+"/topics", nil, http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodPost, server.URL+"/api/workspaces/"+otherWorkspace.ID+"/topics", strings.NewReader(`{"channel_id":"`+otherChannel.ID+`","name":"hidden"}`), http.StatusForbidden)
otherTopics, err := st.ListTopics(ctx, otherWorkspace.ID, owner.ID)
if err != nil {
t.Fatal(err)
}
if len(otherTopics) != 0 {
t.Fatalf("bot token created a topic outside its workspace scope: %#v", otherTopics)
}
expectStatusWithBearer(t, token.Token, http.MethodGet, server.URL+"/api/search?workspace_id="+otherWorkspace.ID+"&q=scope", nil, http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodGet, server.URL+"/api/realtime/events?workspace_id="+otherWorkspace.ID, nil, http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodPost, server.URL+"/api/dms", strings.NewReader(`{"workspace_id":"`+otherWorkspace.ID+`","member_ids":[]}`), http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodGet, server.URL+"/api/routes/"+otherWorkspace.RouteID+"/"+otherChannel.RouteID, nil, http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodGet, server.URL+"/api/channels/"+otherChannel.ID+"/messages", nil, http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodPost, server.URL+"/api/channels/"+otherChannel.ID+"/messages", strings.NewReader(`{"body":"nope"}`), http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodGet, server.URL+"/api/channels/"+otherChannel.ID+"/notification-settings", nil, http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodPatch, server.URL+"/api/channels/"+otherChannel.ID+"/notification-settings", strings.NewReader(`{"preference":"muted"}`), http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodPost, server.URL+"/api/channels/"+otherChannel.ID+"/read", strings.NewReader(`{"seq":1}`), http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodPost, server.URL+"/api/hooks/mattermost/"+otherChannel.ID, strings.NewReader(`{"text":"nope"}`), http.StatusForbidden)
expectStatusWithBearer(t, token.Token, http.MethodPost, server.URL+"/api/hooks/slash/"+otherChannel.ID, strings.NewReader(`command=/nope`), http.StatusForbidden)
Expand Down
73 changes: 73 additions & 0 deletions apps/api/internal/httpapi/channel_notifications.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package httpapi

import (
"errors"
"net/http"

"github.com/go-chi/chi/v5"
"github.com/openclaw/clickclack/apps/api/internal/store"
)

func (s *Server) getChannelNotificationSettings(w http.ResponseWriter, r *http.Request) {
actor, err := s.currentActor(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
if err := actor.requireScope("messages:read"); err != nil {
writeError(w, http.StatusForbidden, err)
return
}
channelID := chi.URLParam(r, "channel_id")
if !s.requireBotChannelWorkspace(w, r, actor, channelID) {
return
}
preference, err := s.store.GetChannelNotificationPreference(
r.Context(),
channelID,
actor.user.ID,
)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"preference": preference})
}

func (s *Server) updateChannelNotificationSettings(w http.ResponseWriter, r *http.Request) {
actor, err := s.currentActor(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
if err := actor.requireScope("messages:write"); err != nil {
writeError(w, http.StatusForbidden, err)
return
}
channelID := chi.URLParam(r, "channel_id")
if !s.requireBotChannelWorkspace(w, r, actor, channelID) {
return
}
var body struct {
Preference string `json:"preference"`
}
if err := readJSON(w, r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if body.Preference != store.ChannelNotifyAll &&
body.Preference != store.ChannelNotifyMentions &&
body.Preference != store.ChannelNotifyMuted {
writeError(w, http.StatusBadRequest, errors.New("preference must be all, mentions, or muted"))
return
}
if err := s.store.UpsertChannelNotificationSettings(r.Context(), store.ChannelNotificationInput{
ChannelID: channelID,
UserID: actor.user.ID,
Preference: body.Preference,
}); err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"preference": body.Preference})
}
48 changes: 48 additions & 0 deletions apps/api/internal/httpapi/channel_notifications_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package httpapi

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/openclaw/clickclack/apps/api/internal/realtime"
"github.com/openclaw/clickclack/apps/api/internal/store"
)

func TestChannelNotificationSettingsHTTP(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newEmptyHTTPStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "channel-notifications@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID)
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(New(st, realtime.NewHub(), Options{}).Handler())
t.Cleanup(server.Close)
endpoint := server.URL + "/api/channels/" + channels[0].ID + "/notification-settings"

initial := getJSON[map[string]string](t, endpoint)
if initial["preference"] != store.ChannelNotifyAll {
t.Fatalf("expected all as the default preference, got %#v", initial)
}
updated := patchJSON[map[string]string](t, endpoint, map[string]string{"preference": store.ChannelNotifyMentions})
if updated["preference"] != store.ChannelNotifyMentions {
t.Fatalf("expected mentions preference, got %#v", updated)
}
loaded := getJSON[map[string]string](t, endpoint)
if loaded["preference"] != store.ChannelNotifyMentions {
t.Fatalf("expected persisted mentions preference, got %#v", loaded)
}
expectStatus(t, http.MethodPatch, endpoint, strings.NewReader(`{"preference":"invalid"}`), http.StatusBadRequest)
expectStatus(t, http.MethodPatch, endpoint, strings.NewReader(`{"preference":`), http.StatusBadRequest)
}
1 change: 1 addition & 0 deletions apps/api/internal/httpapi/correlation_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ func TestMessageEventCorrelationSurvivesResponseRealtimeAndRetrieval(t *testing.
replyEvent := eventByType(t, replyResult.Events, "thread.reply_created")
stateEvent := eventByType(t, replyResult.Events, "thread.state_updated")
assertAPIEventPayloadValue(t, replyEvent, "correlation_id", "corr-api-reply")
assertAPIEventPayloadMissing(t, replyEvent, "author_id")
assertAPIEventPayloadMissing(t, replyEvent, "body")
assertAPIEventPayloadMissing(t, stateEvent, "correlation_id")
liveReply := readEventType(t, conn, "thread.reply_created")
Expand Down
8 changes: 4 additions & 4 deletions apps/api/internal/httpapi/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -1517,7 +1517,7 @@ func (s *Server) createDirectMessage(w http.ResponseWriter, r *http.Request) {
if err == nil && event.ID != "" {
s.publishEvent(r.Context(), event)
if !store.IsActivityMessageKind(message.Kind) {
s.notifyMessageCreated(r.Context(), message)
s.notifyMessageCreated(r.Context(), message, event.MentionedUserIDs)
}
}
writeMessageCreateResult(w, message, event, err)
Expand Down Expand Up @@ -1546,7 +1546,7 @@ func (s *Server) mattermostWebhook(w http.ResponseWriter, r *http.Request) {
message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: act.user.ID, Body: body.Text})
if err == nil {
s.publishEvent(r.Context(), event)
s.notifyMessageCreated(r.Context(), message)
s.notifyMessageCreated(r.Context(), message, event.MentionedUserIDs)
}
writeResultStatus(w, http.StatusCreated, map[string]any{"message": message, "event": event}, err)
}
Expand Down Expand Up @@ -1587,7 +1587,7 @@ func (s *Server) slashCommand(w http.ResponseWriter, r *http.Request) {
message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: act.user.ID, Body: body})
if err == nil {
s.publishEvent(r.Context(), event)
s.notifyMessageCreated(r.Context(), message)
s.notifyMessageCreated(r.Context(), message, event.MentionedUserIDs)
}
writeResultStatus(w, http.StatusCreated, map[string]any{
"response_type": "in_channel",
Expand Down Expand Up @@ -1661,7 +1661,7 @@ func (s *Server) invokeRegisteredSlashCommand(w http.ResponseWriter, r *http.Req
return
}
s.publishEvent(r.Context(), event)
s.notifyMessageCreated(r.Context(), message)
s.notifyMessageCreated(r.Context(), message, event.MentionedUserIDs)
}
writeJSON(w, http.StatusOK, map[string]any{
"response_type": callback.ResponseType,
Expand Down
Loading