Metadata-Version: 2.5
Name: django-huddle
Version: 0.2.1
Summary: Slack-style team chat for Django: channels, direct messages, threads and @mentions over Django Channels.
Project-URL: bugs, https://github.com/Streamlined-Analytics/django-huddle/issues
Project-URL: changelog, https://github.com/Streamlined-Analytics/django-huddle/releases
Project-URL: homepage, https://github.com/Streamlined-Analytics/django-huddle
Author-email: Ben Atkinson <ben@streamlinedanalytics.co.uk>
Maintainer-email: Ben Atkinson <ben@streamlinedanalytics.co.uk>
License: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Chat
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: channels>=4.2
Requires-Dist: django>=6
Provides-Extra: api
Requires-Dist: djangorestframework>=3.16; extra == 'api'
Description-Content-Type: text/markdown

# django-huddle

Slack-style team chat for Django: **channels, direct messages, threads,
@mentions and `#` links to your own objects**, live over [Django Channels](https://channels.readthedocs.io/).

- **Channels and DMs.** Public and private named channels, plus one-to-one and
  group direct messages (one conversation per group of people).
- **Threads.** Reply to any message in a side thread, as in Slack. Threads
  are one level deep and track reply counts, followers and read state.
- **@mentions.** Autocomplete offers only people who can read the channel, and
  typing the exact name (`@Ben Atkinson`) or a handle (`@benatkinson`) works too. Mentions are stored by user
  id, so renaming someone never breaks old messages.
- **`#` references.** `#B3.2` or `#0001` can link to your own objects (requirements,
  tickets, risks), through resolvers you write. Each viewer sees only what they
  may see.
- **Live.** New messages, edits, deletions, thread replies and unread badges
  arrive over a websocket.
- **Your permissions.** One policy class decides every visibility and action
  check. Attach channels to your own objects (projects, teams) and say who can
  see them.
- **Bring your own frontend, or don't.** It ships a working htmx UI whose every
  piece is an overridable template. There's also a REST API, and a JSON
  websocket mode for React, mobile or anything else.

> **Status:** alpha. The public API may change before 1.0.

## Requirements

- Python 3.12+
- Django 6.0+
- Django Channels 4.2+ with a channel layer (Redis in production)
- Optional: Django REST Framework 3.16+ for the REST API

## Install

```bash
pip install django-huddle          # HTML UI + websocket
pip install "django-huddle[api]"   # plus the REST API
```

```python
# settings.py
INSTALLED_APPS = [
    # ...
    "channels",
    "rest_framework",  # only for the REST API
    "huddle",
]
ASGI_APPLICATION = "myproject.asgi.application"
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {"hosts": ["redis://localhost:6379/1"]},
    },
}
HUDDLE_BASE_TEMPLATE = "base.html"  # must define {% block content %} and load htmx + htmx-ext-ws
```

```python
# urls.py
urlpatterns = [
    path("chat/", include("huddle.urls")),
    path("api/chat/", include("huddle.api.urls")),  # optional
]
```

```python
# asgi.py
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from huddle.routing import websocket_urlpatterns

application = ProtocolTypeRouter(
    {
        "http": django_asgi_app,
        "websocket": AllowedHostsOriginValidator(AuthMiddlewareStack(URLRouter(websocket_urlpatterns))),
    }
)
```

Then run `python manage.py migrate`.

## Permissions: the chat policy

Every decision goes through one `huddle.policy.ChatPolicy`. The default allows:

- public unscoped channels for every signed-in user;
- private channels and DMs for their members;
- **scoped channels for nobody**, until you say otherwise.

To attach channels to your own objects, subclass the policy:

```python
from django.db.models import Q
from huddle.policy import ChatPolicy


class ProjectChatPolicy(ChatPolicy):
    def scope_q(self, user):
        projects = Project.objects.visible_to(user).values("pk")
        return Q(scope_content_type__isnull=True) | self.scopes_q(Project, projects)

    def can_create_channel(self, user, scope):
        return scope is not None and user.has_perm("projects.manage", scope)

    def mentionable_users(self, channel):
        if channel.is_private or channel.scope is None:
            return super().mentionable_users(channel)
        return channel.scope.members.filter(is_active=True)

    def direct_message_candidates(self, user):
        return User.objects.filter(projects__in=Project.objects.visible_to(user)).exclude(pk=user.pk).distinct()
```

```python
HUDDLE_POLICY = "myapp.chat.ProjectChatPolicy"
```

Every lookup (HTML, API and websocket) goes through
`Channel.objects.visible_to(user)`, so a channel someone can't see returns a
**404**, never a 403 that would confirm it exists. Every websocket event is
re-checked for each viewer before it is sent, so losing access takes effect
immediately.

Overridable methods: `scope_q`, `filter_visible`, `can_view`, `can_post`,
`can_create_channel`, `can_manage_channel`, `can_edit_message`,
`can_delete_message`, `mentionable_users`, `direct_message_candidates`.

## Mentions

The composer autocompletes `@` from `ChatPolicy.mentionable_users(channel)` as
you type, including across a space (`@Ben A`), and posts the picked user ids. A
name typed without picking also counts, case-insensitively:

- the full display name (`@Ben Atkinson`). The longest name wins, so `@Ann Lee`
  beats `@Ann`, and it must end at a word boundary, so `@Ann Leeds` is not Ann Lee;
- the display name with the spaces removed (`@benatkinson`), or the email local
  part (`@b.atkinson`).

If the matched name or handle fits more than one mentionable user, nobody is
mentioned and the text stays as typed. Set `HUDDLE_FREE_TYPED_MENTIONS = False`
to require picking.

### Editing keeps links whose text remains

When a message is edited, every existing mention or reference whose text is
still there is kept as it was, even if the editor can no longer see the person
or the item. Delete the text and the link goes. The editor's text shows a
reference they can no longer see as `#unavailable-1`, `#unavailable-2`… (one
number per hidden item), so each can be kept or removed on its own. New `@` and
`#` text in an edit follows the normal rules, and only links already on that
message can be kept, so nothing can be forged. Kept links send no new
`user_mentioned` or `reference_added` signal. The HTML edit form, the REST
`PATCH` and `ChatService.edit_message()` behave the same way;
`ChatService.kept_links(user, message)` shows what an edit would keep.

## `#` references to your own objects

Messages can link to objects in your project: `where are we up to with #B3.2?`
Write one resolver per kind of object and list them in settings:

```python
from huddle.references import ReferenceResolver


class RequirementResolver(ReferenceResolver):
    kind = "requirement"  # stored as <#requirement:42>

    def requirements(self, channel, user):
        project = channel.scope  # None in DMs and unscoped channels
        if not isinstance(project, Project) or not project.members.filter(pk=user.pk).exists():
            return Requirement.objects.none()
        return Requirement.objects.filter(project=project)

    def search(self, channel, user, query, *, limit):  # composer autocomplete
        return self.requirements(channel, user).filter(code__istartswith=query)[:limit]

    def resolve_code(self, channel, user, code):  # a typed #code at send time
        return self.requirements(channel, user).filter(code__iexact=code).first()

    def get(self, channel, user, ids):  # render time, in bulk
        return self.requirements(channel, user).filter(pk__in=ids)

    def code(self, obj):
        return obj.code  # what people type after #

    def label(self, obj):
        return f"#{obj.code} {obj.title}"  # the chip text; url() defaults to get_absolute_url()
```

```python
HUDDLE_REFERENCE_RESOLVERS = ["myapp.chat.RequirementResolver", "myapp.chat.RiskResolver"]
```

- **Every method only returns what `user` may see in `channel`.** Huddle has
  already checked the user may read the channel; the object is yours to check.
  Ids arrive as strings, and picked ids come from the client: if `get()` raises
  `ValueError`, `TypeError` or `ValidationError` on them, huddle answers 400.
- **`content_type` is only recorded when the stored id is the primary key.** If
  you override `object_id()`, look references up by `kind` and `object_id`.
- **DMs have no scope.** `channel.scope` is `None` there (and in unscoped
  channels). Return nothing if your objects only make sense inside a scope.
- **Stored by id.** The body keeps `<#requirement:42>`, so renumbering an item
  doesn't break old messages.
- **Checked again when shown.** Each viewer's page re-asks `get()`. Someone who
  can no longer see the item gets a neutral `#unavailable`, never its label
  (`#unavailable-N` in the edit box, so editing keeps it).
- **Unknown or forbidden codes stay plain text.** A hand-typed `<#…>` token is
  neutralised, so references can't be forged.
- **Conflicts.** A code picked from autocomplete is exact. A free-typed code goes
  to the resolvers in settings order, and the first one that resolves it wins.
- **No resolvers configured** means `#` stays plain text, as in 0.1.0.

Each reference is recorded as a `huddle.models.MessageReference` row (`message`,
`kind`, `object_id`, and `content_type`/`target` when the resolver returned a
model instance), kept in step with edits and deletes. To show "discussed in
chat" on an item:

```python
MessageReference.objects.for_target(requirement).visible_to(request.user).select_related("message")
```

The example project has a complete project-scoped policy and resolver in
[`example/chat.py`](example/chat.py).

## Writing from your own code

```python
from huddle.services import ChatService

chat = ChatService()
channel = chat.create_channel(user, "site-visits", scope=project)
message = chat.post_message(user, channel, "Booked for Tuesday @Ann Lee", mentioned=[ann])
chat.post_message(ann, channel, "Thanks!", parent=message)  # a thread reply
chat.post_message(user, channel, "See #B3.2", references=["requirement:42"])  # a picked reference
dm = chat.open_direct(user, [ann, bob])
```

The service enforces the policy and raises
`huddle.exceptions.ChatPermissionDeniedError` (and friends). Each exception
has a `.context` dict for structured logging.

## Signals: notifications and integrations

Signals are sent **after the transaction commits**:

| Signal | Arguments |
|---|---|
| `huddle.signals.message_posted` | `message`, `channel`, `author` |
| `huddle.signals.user_mentioned` | `message`, `user` (once per newly mentioned user, including on edit) |
| `huddle.signals.thread_replied` | `message`, `thread`, `recipients` (the thread's followers, minus the author) |
| `huddle.signals.reference_added` | `message`, `reference` (the `MessageReference` row), `target` (your object); once per newly referenced object, including on edit |

```python
@receiver(user_mentioned)
def notify_mention(sender, message, user, **kwargs):
    notify([user], "chat.mention", target=message)
```

## Customising the UI

Every fragment is its own template under `huddle/components/`, and pages
extend `huddle/layout.html`. To restyle one piece, override one file in your
project's templates directory:

| Template | What it draws |
|---|---|
| `huddle/components/message.html` | One message, with its actions and thread summary |
| `huddle/components/composer.html`, `composer_fields.html` | The message box, @mention and `#` reference autocomplete |
| `huddle/components/mention_results.html`, `reference_results.html` | The autocomplete options |
| `huddle/components/sidebar.html`, `sidebar_entry.html` | Channel and DM list, unread badges |
| `huddle/components/thread.html` | The thread side panel |
| `huddle/components/channel_pane.html` | Channel header, timeline and composer |
| `huddle/layout.html` | The three-column chat layout |

Each message template receives a `message_view` with `message`,
`author_name`, `html` (safe, escaped), `text` (editable), `references` (the
objects this viewer may see), `can_edit`, `can_delete` and `is_own`. Reference
chips have the class `huddle-reference` (`huddle-reference--unavailable` for the
placeholder).

The default stylesheet (`huddle/huddle.css`) is plain CSS with custom
properties (`--huddle-accent`, `--huddle-border`, …) for theming.

## REST API (`django-huddle[api]`)

| Method and path | Purpose |
|---|---|
| `GET /channels/` | Your channels and DMs (`?browse=true`: every visible named channel) |
| `POST /channels/` | Create a channel `{name, topic, is_private}` |
| `GET /channels/{id}/` | One channel, with `is_member` and `unread_count` |
| `POST /channels/direct/` | Open a DM `{user_ids: [...]}` |
| `POST /channels/{id}/join/` · `leave/` · `read/` | Membership and read state |
| `GET /channels/{id}/messages/?before=&limit=` | A page of top-level messages, oldest first |
| `POST /channels/{id}/messages/` | Post `{text, mention_ids, reference_ids, parent_id}` |
| `GET /channels/{id}/mentionable/?q=` | People who can be @mentioned |
| `GET /channels/{id}/references/?q=` | Objects that can be `#`-referenced, as `{kind, id, code, label, url}` |
| `GET`, `PATCH`, `DELETE /messages/{id}/` | Read, edit `{text, mention_ids, reference_ids}`, delete |
| `GET /messages/{id}/replies/` · `POST read/` | A thread, and marking it read |

`reference_ids` are `kind:id` strings, as returned by the references endpoint.
Messages come back with `body` (stored tokens), `text` (editable, with
`@Name` and `#code`), `html` (rendered and safe), `mention_ids`, `references`
(`[{kind, id, code, label, url}]`, only what this viewer may see), `author`, the timestamps,
`reply_count`, and per-viewer `can_edit`, `can_delete` and `is_own`. To
attach API-created channels to your own objects, subclass
`huddle.api.views.ChannelViewSet` and override `get_scope()`.

## Websocket

Connect to `/ws/huddle/` (`HUDDLE_WEBSOCKET_URL`) and send
`{"subscribe": <channel id>}` / `{"unsubscribe": <channel id>}`.

- **HTML mode** (the default) sends htmx out-of-band swaps; the bundled UI
  needs no client-side rendering.
- **JSON mode** (`/ws/huddle/?format=json`) sends
  `{"type": "message.created" | "message.updated" | "message.deleted", "message": {...}}`
  and `{"type": "channel.updated", "channel": {...}}`, in the same shapes as
  the REST API.

If you already run your own consumer (for example, one socket per page that
also carries notifications), mix in `huddle.consumers.HuddleConsumerMixin`,
then call `huddle_connect()`, `huddle_receive(content)` and
`huddle_disconnect()`.

## Message format

Bodies are stored as plain text with tokens: `<@user-pk>` for mentions and
`<#kind:id>` for references to your own objects. Rendering escapes everything
else, so there's no HTML sanitiser to keep patched.

## Settings

| Setting | Default |
|---|---|
| `HUDDLE_POLICY` | `"huddle.policy.ChatPolicy"` |
| `HUDDLE_BASE_TEMPLATE` | `"huddle/base.html"` (loads htmx from unpkg; replace it in production) |
| `HUDDLE_PAGE_SIZE` | `50` |
| `HUDDLE_MAX_MESSAGE_LENGTH` | `10000` |
| `HUDDLE_WEBSOCKET_URL` | `"/ws/huddle/"` |
| `HUDDLE_REFERENCE_RESOLVERS` | `[]` (dotted paths to `ReferenceResolver` subclasses; earlier entries win conflicts) |
| `HUDDLE_FREE_TYPED_MENTIONS` | `True` (typed `@Full Name` and `@handles` mention without picking) |

## Development

```bash
uv sync
uv run pytest
uv run mypy src/ tests/ example/
uv run ruff check . && uv run ruff format --check .
uv run python manage.py migrate && uv run daphne example.asgi:application   # the example project
```

Design decisions are recorded in [`docs/adr/`](docs/adr/).

## License

MIT
