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
1 change: 0 additions & 1 deletion .prettierrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,4 @@
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2

}
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

A decentralized, chat-first platform that combines **real-time messaging, token payments, and community-driven funding** into a single seamless experience.



This project reimagines how people coordinate, transact, and build together online by embedding financial actions directly into conversations. Users can send tokens as easily as messages, contribute to shared group treasuries, and fund ideas through lightweight, on-chain proposals—all without leaving the chat interface.

Built on blockchain infrastructure and modern messaging protocols, the platform bridges the gap between Web2 usability and Web3 ownership.
Expand Down
4 changes: 4 additions & 0 deletions apps/ai_agent/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ wheels/

# Virtual environments
.venv

.ruff_cache
.mypy_cache
.coverage
11 changes: 7 additions & 4 deletions apps/ai_agent/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json
import os
from typing import Literal
from typing import Literal, cast

import uvicorn
import weaviate
Expand All @@ -11,7 +11,7 @@
try:
from openai import OpenAI
except ImportError: # pragma: no cover - exercised when the dependency is absent
OpenAI = None
OpenAI = None # type: ignore[assignment, misc]

app = FastAPI(title="AI Agent API")

Expand Down Expand Up @@ -79,7 +79,8 @@ def _openai_client():
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not configured")
from openai import OpenAI # imported lazily so missing package gives a clear error
if OpenAI is None:
raise HTTPException(status_code=500, detail="openai package is not installed")

return OpenAI(api_key=api_key)

Expand Down Expand Up @@ -172,7 +173,9 @@ def summarise_proposal(request: ProposalSummariseRequest):
risk = "medium"

# Pydantic re-validates via response_model before the response is sent.
return ProposalSummariseResponse(summary=summary, risk=risk)
return ProposalSummariseResponse(
summary=summary, risk=cast(Literal["low", "medium", "high"], risk)
)


@app.post("/index/message")
Expand Down
5 changes: 1 addition & 4 deletions apps/ai_agent/tests/test_chat.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Unit tests for POST /chat (issue #144)."""

import pytest
from unittest.mock import MagicMock

_BASE_BODY = {
Expand All @@ -27,9 +26,7 @@ def test_missing_api_key_returns_500(monkeypatch, client):

def test_valid_request_returns_reply(mock_openai, client):
mock_client = mock_openai.return_value
mock_client.chat.completions.create.return_value = _fake_chat_reply(
"Hello from Clicked AI!"
)
mock_client.chat.completions.create.return_value = _fake_chat_reply("Hello from Clicked AI!")

response = client.post("/chat", json=_BASE_BODY)
assert response.status_code == 200
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/__tests__/conversations.cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ describe('GET /conversations/:id/search', () => {
expect(res.status).toBe(410);
expect(res.body).toEqual({
error: 'Server-side search removed; search is now client-side over decrypted messages',
docs: 'https://github.com/DripWave/clicked/blob/main/docs/message-encryption-migration.md'
docs: 'https://github.com/DripWave/clicked/blob/main/docs/message-encryption-migration.md',
});
});
});
Expand Down
132 changes: 79 additions & 53 deletions apps/backend/src/__tests__/deliveryReceipts.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from 'events';

// ── Mock DB ────────────────────────────────────────────────────────────────

const mockFindFirst = vi.fn();
const mockEnvelopeFindFirst = vi.fn();
const mockUpdate = vi.fn();
const mockSelect = vi.fn();
const mockQuery = vi.fn();
Expand All @@ -13,6 +13,7 @@
query: {
conversationMembers: { findFirst: mockFindFirst, findMany: mockQuery },
messages: { findFirst: mockFindFirst },
messageEnvelopes: { findFirst: mockEnvelopeFindFirst },
},
select: mockSelect,
update: mockUpdate,
Expand Down Expand Up @@ -42,128 +43,153 @@
// ── Tests ──────────────────────────────────────────────────────────────────

describe('Delivery Receipts', () => {
let mockIo: any;

Check warning on line 46 in apps/backend/src/__tests__/deliveryReceipts.test.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Test

Unexpected any. Specify a different type

Check warning on line 46 in apps/backend/src/__tests__/deliveryReceipts.test.ts

View workflow job for this annotation

GitHub Actions / build

Unexpected any. Specify a different type

Check warning on line 46 in apps/backend/src/__tests__/deliveryReceipts.test.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Test

Unexpected any. Specify a different type
let mockSocket: any;

beforeEach(() => {
vi.clearAllMocks();

mockIo = {
to: vi.fn().mockReturnThis(),
emit: vi.fn(),
};

mockSocket = {
auth: {
userId: 'user-123',
deviceId: 'device-456',
},
emit: vi.fn(),
rooms: new Set(['conversation-789']),
};
mockIo.volatile = mockIo;

// Mock successful membership check
mockFindFirst.mockResolvedValue({ conversationId: 'conversation-789', userId: 'user-123' });


// Mock envelope not yet delivered by default (allows the update to proceed)
mockEnvelopeFindFirst.mockResolvedValue(undefined);

// Mock message find
mockFindFirst.mockResolvedValueOnce({ id: 'message-abc', senderId: 'sender-999', conversationId: 'conversation-789' });

mockFindFirst.mockResolvedValueOnce({
id: 'message-abc',
senderId: 'sender-999',
conversationId: 'conversation-789',
});

// Mock update success
mockUpdate.mockResolvedValue({ rowCount: 1 });

// Mock active devices query
mockSelect.mockResolvedValue([
{ id: 'device-456' },
{ id: 'device-457' },
]);

// Mock envelopes query
mockSelect.mockResolvedValueOnce([
{ recipientDeviceId: 'device-456', deliveredAt: new Date() },
{ recipientDeviceId: 'device-457', deliveredAt: null },
]);
mockUpdate.mockReturnValue({
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue({ rowCount: 1 }),
});

// Each isMessageFullyDeliveredToUser() call issues two selects in order:
// active devices, then envelopes. Alternate responses so repeated calls
// (e.g. the idempotency test) keep getting a valid chain.
let selectCallCount = 0;
mockSelect.mockImplementation(() => {
selectCallCount += 1;
const isEnvelopeQuery = selectCallCount % 2 === 0;
return {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(
isEnvelopeQuery
? [
{ recipientDeviceId: 'device-456', deliveredAt: new Date() },
{ recipientDeviceId: 'device-457', deliveredAt: null },
]
: [{ id: 'device-456' }, { id: 'device-457' }],
),
};
});
});

it('should handle per-device delivery receipt', async () => {
// Import dynamically after mocks are set up
const { handleDeviceDeliveryReceipt } = await import('../services/deliveryAggregation.js');

await handleDeviceDeliveryReceipt(
mockIo,
null, // redis
'message-abc',
'device-456',
'user-123',
'conversation-789'
'conversation-789',
);

// Verify database update was called
expect(mockUpdate).toHaveBeenCalled();

// Verify room-based emission
expect(mockIo.to).toHaveBeenCalledWith('room:conversation:conversation-789');
expect(mockIo.emit).toHaveBeenCalledWith('device_delivery_receipt', expect.objectContaining({
conversationId: 'conversation-789',
messageId: 'message-abc',
recipientUserId: 'user-123',
recipientDeviceId: 'device-456',
}));
expect(mockIo.emit).toHaveBeenCalledWith(
'device_delivery_receipt',
expect.objectContaining({
conversationId: 'conversation-789',
messageId: 'message-abc',
recipientUserId: 'user-123',
recipientDeviceId: 'device-456',
}),
);
});

it('should validate isMessageFullyDeliveredToUser correctly', async () => {
const { isMessageFullyDeliveredToUser } = await import('../services/deliveryAggregation.js');


const chainSelect = (result: unknown[]) => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(result),
});

// First device delivered, second not delivered
mockSelect
.mockReset()
.mockResolvedValueOnce([{ id: 'device-456' }, { id: 'device-457' }]) // active devices
.mockResolvedValueOnce([
{ recipientDeviceId: 'device-456', deliveredAt: new Date() },
{ recipientDeviceId: 'device-457', deliveredAt: null },
]); // envelopes

.mockReturnValueOnce(chainSelect([{ id: 'device-456' }, { id: 'device-457' }])) // active devices
.mockReturnValueOnce(
chainSelect([
{ recipientDeviceId: 'device-456', deliveredAt: new Date() },
{ recipientDeviceId: 'device-457', deliveredAt: null },
]),
); // envelopes

const notFullyDelivered = await isMessageFullyDeliveredToUser('message-abc', 'user-123');
expect(notFullyDelivered).toBe(false);

// Both devices delivered
mockSelect
.mockReset()
.mockResolvedValueOnce([{ id: 'device-456' }, { id: 'device-457' }]) // active devices
.mockResolvedValueOnce([
{ recipientDeviceId: 'device-456', deliveredAt: new Date() },
{ recipientDeviceId: 'device-457', deliveredAt: new Date() },
]); // envelopes

.mockReturnValueOnce(chainSelect([{ id: 'device-456' }, { id: 'device-457' }])) // active devices
.mockReturnValueOnce(
chainSelect([
{ recipientDeviceId: 'device-456', deliveredAt: new Date() },
{ recipientDeviceId: 'device-457', deliveredAt: new Date() },
]),
); // envelopes

const fullyDelivered = await isMessageFullyDeliveredToUser('message-abc', 'user-123');
expect(fullyDelivered).toBe(true);
});

it('should be idempotent for duplicate delivery receipts', async () => {
const { handleDeviceDeliveryReceipt } = await import('../services/deliveryAggregation.js');

// First call
await handleDeviceDeliveryReceipt(
mockIo,
null,
'message-abc',
'device-456',
'user-123',
'conversation-789'
'conversation-789',
);

const firstCallCount = mockUpdate.mock.calls.length;

// Simulate the envelope now being marked delivered from the first call,
// so the duplicate receipt short-circuits before touching the DB again.
mockEnvelopeFindFirst.mockResolvedValueOnce({ deliveredAt: new Date() });

// Second call with same parameters
await handleDeviceDeliveryReceipt(
mockIo,
null,
'message-abc',
'device-456',
'user-123',
'conversation-789'
'conversation-789',
);

// Should have same number of update calls (idempotent)
expect(mockUpdate.mock.calls.length).toBe(firstCallCount);
});
});
});
21 changes: 15 additions & 6 deletions apps/backend/src/__tests__/file.messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,19 @@ const mockInsert = vi.fn();
const mockFindMany = vi.fn();
const mockUpdate = vi.fn();

vi.mock('../db/index.js', () => ({
db: {
vi.mock('../db/index.js', () => {
const db: Record<string, unknown> = {
query: {
conversationMembers: { findFirst: mockMemberFindFirst, findMany: mockFindMany },
messages: { findFirst: vi.fn() },
files: { findFirst: mockFileFindFirst },
},
insert: mockInsert,
update: mockUpdate,
},
}));
transaction: vi.fn((cb: (tx: unknown) => unknown) => cb(db)),
};
return { db };
});

vi.mock('../db/schema.js', () => ({
conversationMembers: {},
Expand Down Expand Up @@ -124,6 +126,13 @@ function readyFile(

beforeEach(() => {
vi.clearAllMocks();

// Default: the per-conversation sequence-number bump succeeds.
mockUpdate.mockReturnValue({
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([{ newSeq: 1 }]),
});
});

describe('send_file_message socket event', () => {
Expand Down Expand Up @@ -509,8 +518,8 @@ describe('send_file_message socket event', () => {
const insertedValues = (valuesFn.mock.calls[0] as unknown[])[0] as Record<string, unknown>;
expect(insertedValues).not.toHaveProperty('fileKey');

// The `content` field is stored as-is (opaque encrypted blob)
expect(insertedValues.content).toBe(ENVELOPE_CIPHERTEXT);
// The `ciphertext` field is stored as-is (opaque encrypted blob)
expect(insertedValues.ciphertext).toBe(ENVELOPE_CIPHERTEXT);
});

it('supports all valid file content types: file, image, video, audio', async () => {
Expand Down
Loading
Loading