Skip to content

Commit 6f49092

Browse files
authored
Release Candidate v4.3.2 (#237)
2 parents eddd656 + 9b7cdc8 commit 6f49092

35 files changed

+1831
-504
lines changed

.version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
4.2.3
1+
4.3.2

Pipfile.lock

Lines changed: 467 additions & 472 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/open-api-users.yaml

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ openapi: 3.0.3
22
info:
33
title: The Agent's user-facing API
44
description: The user-facing parts of The Agent's API service (excluding system-level endpoints, chat completion, maintenance endpoints, etc.)
5-
version: 3.9.3
5+
version: 4.3.1
66
license:
77
name: MIT
88
url: https://opensource.org/licenses/MIT
@@ -317,6 +317,99 @@ paths:
317317
security:
318318
- bearerAuth: []
319319

320+
/user/{user_id_hex}/connect-key:
321+
get:
322+
summary: Get user's connect key
323+
description: Retrieve the connect key for the specified user, used to merge profiles from different platforms
324+
operationId: getConnectKey
325+
tags: [Profile Connection]
326+
parameters:
327+
- name: user_id_hex
328+
in: path
329+
required: true
330+
schema:
331+
type: string
332+
description: User ID in hexadecimal format
333+
responses:
334+
"200":
335+
description: Connect key retrieved successfully
336+
content:
337+
application/json:
338+
schema:
339+
$ref: "#/components/schemas/ConnectKeyResponse"
340+
"401":
341+
$ref: "#/components/responses/UnauthorizedError"
342+
"403":
343+
$ref: "#/components/responses/ForbiddenError"
344+
"500":
345+
$ref: "#/components/responses/ServerError"
346+
security:
347+
- bearerAuth: []
348+
349+
/user/{user_id_hex}/regenerate-connect-key:
350+
post:
351+
summary: Regenerate user's connect key
352+
description: Generate a new connect key for the specified user, invalidating the old key
353+
operationId: regenerateConnectKey
354+
tags: [Profile Connection]
355+
parameters:
356+
- name: user_id_hex
357+
in: path
358+
required: true
359+
schema:
360+
type: string
361+
description: User ID in hexadecimal format
362+
responses:
363+
"200":
364+
description: Connect key regenerated successfully
365+
content:
366+
application/json:
367+
schema:
368+
$ref: "#/components/schemas/ConnectKeyResponse"
369+
"401":
370+
$ref: "#/components/responses/UnauthorizedError"
371+
"403":
372+
$ref: "#/components/responses/ForbiddenError"
373+
"500":
374+
$ref: "#/components/responses/ServerError"
375+
security:
376+
- bearerAuth: []
377+
378+
/user/{user_id_hex}/connect-key/{connect_key}/merge:
379+
post:
380+
summary: Connect profiles from different platforms
381+
description: Merge two user profiles from different platforms (e.g., Telegram and WhatsApp) into a single unified profile
382+
operationId: connectProfiles
383+
tags: [Profile Connection]
384+
parameters:
385+
- name: user_id_hex
386+
in: path
387+
required: true
388+
schema:
389+
type: string
390+
description: User ID in hexadecimal format (the requester)
391+
- name: connect_key
392+
in: path
393+
required: true
394+
schema:
395+
type: string
396+
description: Connect key of the target profile to merge with
397+
responses:
398+
"200":
399+
description: Profiles connected successfully
400+
content:
401+
application/json:
402+
schema:
403+
$ref: "#/components/schemas/SettingsLinkResponse"
404+
"401":
405+
$ref: "#/components/responses/UnauthorizedError"
406+
"403":
407+
$ref: "#/components/responses/ForbiddenError"
408+
"500":
409+
$ref: "#/components/responses/ServerError"
410+
security:
411+
- bearerAuth: []
412+
320413
components:
321414
securitySchemes:
322415
bearerAuth:
@@ -378,6 +471,10 @@ components:
378471
UserSettingsPayload:
379472
type: object
380473
properties:
474+
full_name:
475+
type: string
476+
nullable: true
477+
description: User's full name
381478
open_ai_key:
382479
type: string
383480
nullable: true
@@ -386,6 +483,10 @@ components:
386483
type: string
387484
nullable: true
388485
description: Anthropic API key
486+
google_ai_key:
487+
type: string
488+
nullable: true
489+
description: Google AI API key
389490
perplexity_key:
390491
type: string
391492
nullable: true
@@ -502,6 +603,10 @@ components:
502603
type: string
503604
nullable: true
504605
description: Anthropic API key (masked)
606+
google_ai_key:
607+
type: string
608+
nullable: true
609+
description: Google AI API key (masked)
505610
perplexity_key:
506611
type: string
507612
nullable: true
@@ -867,6 +972,26 @@ components:
867972
required:
868973
- settings_link
869974

975+
ConnectKeyResponse:
976+
type: object
977+
properties:
978+
connect_key:
979+
type: string
980+
nullable: false
981+
description: Connect key, used to merge profiles from different platforms
982+
required:
983+
- connect_key
984+
985+
SettingsLinkResponse:
986+
type: object
987+
properties:
988+
settings_link:
989+
type: string
990+
nullable: false
991+
description: Link to the settings page
992+
required:
993+
- settings_link
994+
870995
ErrorResponse:
871996
type: object
872997
properties:

src/api/mapper/user_mapper.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ def api_to_domain(payload: UserSettingsPayload, existing_user: User) -> UserSave
1111
# All fields are already stripped in the payload model validators
1212
user_save = UserSave(**existing_user.model_dump())
1313

14+
if payload.full_name is not None:
15+
user_save.full_name = payload.full_name if payload.full_name else None
16+
1417
if payload.open_ai_key is not None:
1518
user_save.open_ai_key = SecretStr(payload.open_ai_key) if payload.open_ai_key else None
1619
if payload.anthropic_key is not None:
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from pydantic import BaseModel
2+
3+
4+
class ConnectKeyResponse(BaseModel):
5+
connect_key: str
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from pydantic import BaseModel
2+
3+
4+
class SettingsLinkResponse(BaseModel):
5+
settings_link: str

src/api/model/user_settings_payload.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55

66
class UserSettingsPayload(BaseModel):
7+
full_name: str | None = None
8+
79
open_ai_key: str | None = None
810
anthropic_key: str | None = None
911
google_ai_key: str | None = None
@@ -30,6 +32,7 @@ class UserSettingsPayload(BaseModel):
3032

3133
# noinspection PyNestedDecorators
3234
@field_validator(
35+
"full_name",
3336
"open_ai_key",
3437
"anthropic_key",
3538
"google_ai_key",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from api.model.connect_key_response import ConnectKeyResponse
2+
from api.model.settings_link_response import SettingsLinkResponse
3+
from db.model.chat_config import ChatConfigDB
4+
from di.di import DI
5+
from features.connect.profile_connect_service import ProfileConnectService
6+
from util import log
7+
8+
9+
class ProfileConnectController:
10+
11+
__di: DI
12+
13+
def __init__(self, di: DI):
14+
self.__di = di
15+
16+
def get_connect_key(self, user_id_hex: str) -> ConnectKeyResponse:
17+
log.d(f"Fetching connect key for user '{user_id_hex}'")
18+
user = self.__di.authorization_service.authorize_for_user(self.__di.invoker, user_id_hex)
19+
return ConnectKeyResponse(connect_key = user.connect_key)
20+
21+
def regenerate_connect_key(self, user_id_hex: str) -> ConnectKeyResponse:
22+
log.d(f"Regenerating connect key for user '{user_id_hex}'")
23+
user = self.__di.authorization_service.authorize_for_user(self.__di.invoker, user_id_hex)
24+
25+
new_key = self.__di.profile_connect_service.regenerate_connect_key(user)
26+
log.i(f"Successfully regenerated connect key for user '{user_id_hex}'")
27+
return ConnectKeyResponse(connect_key = new_key)
28+
29+
def connect_profiles(
30+
self,
31+
user_id_hex: str,
32+
target_connect_key: str,
33+
chat_type: ChatConfigDB.ChatType,
34+
) -> SettingsLinkResponse:
35+
normalized_connect_key = target_connect_key.strip().upper()
36+
log.d(f"Connecting profiles for user '{user_id_hex}' with target key '{normalized_connect_key}'")
37+
user = self.__di.authorization_service.authorize_for_user(self.__di.invoker, user_id_hex)
38+
result, message = self.__di.profile_connect_service.connect_profiles(user, normalized_connect_key)
39+
40+
if result == ProfileConnectService.Result.failure:
41+
raise ValueError(message)
42+
43+
settings_link_response = self.__di.settings_controller.create_settings_link(chat_type = chat_type)
44+
log.i(f"Successfully connected profiles for user '{user_id_hex}'")
45+
return settings_link_response

src/api/settings_controller.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from api.mapper.user_mapper import api_to_domain, domain_to_api
88
from api.model.chat_settings_payload import ChatSettingsPayload
99
from api.model.external_tools_response import ExternalToolProviderResponse, ExternalToolResponse, ExternalToolsResponse
10+
from api.model.settings_link_response import SettingsLinkResponse
1011
from api.model.user_settings_payload import UserSettingsPayload
1112
from db.model.chat_config import ChatConfigDB
1213
from db.schema.chat_config import ChatConfig, ChatConfigSave
@@ -39,7 +40,11 @@ def __validate_settings_type(self, settings_type: str) -> SettingsType:
3940
raise ValueError(log.e(f"Invalid settings type '{settings_type}'"))
4041
return settings_type
4142

42-
def create_settings_link(self, raw_settings_type: str | None = None, chat_type: ChatConfigDB.ChatType | None = None) -> str:
43+
def create_settings_link(
44+
self,
45+
raw_settings_type: str | None = None,
46+
chat_type: ChatConfigDB.ChatType | None = None,
47+
) -> SettingsLinkResponse:
4348
chat_type = chat_type or self.__di.invoker_chat_type
4449
if not chat_type:
4550
raise ValueError(log.e("Chat type not provided and invoker_chat is not available"))
@@ -71,7 +76,7 @@ def create_settings_link(self, raw_settings_type: str | None = None, chat_type:
7176

7277
valid_until = datetime.now() + timedelta(minutes = config.jwt_expires_in_minutes * 10)
7378
shortener = self.__di.url_shortener(long_url, valid_until = valid_until)
74-
return shortener.execute()
79+
return SettingsLinkResponse(settings_link = shortener.execute())
7580

7681
def create_help_link(self, chat_type: ChatConfigDB.ChatType | None = None) -> str:
7782
chat_type = chat_type or self.__di.invoker_chat_type
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""add_connect_key_to_users
2+
3+
Revision ID: 5472fe3215da
4+
Revises: a0ecabe6256b
5+
Create Date: 2025-11-02 19:31:36.868557
6+
7+
"""
8+
import secrets
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
from sqlalchemy.orm import Session
14+
15+
CONNECT_KEY_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
16+
17+
18+
# revision identifiers, used by Alembic.
19+
revision: str = "5472fe3215da"
20+
down_revision: Union[str, None] = "a0ecabe6256b"
21+
branch_labels: Union[str, Sequence[str], None] = None
22+
depends_on: Union[str, Sequence[str], None] = None
23+
24+
25+
def upgrade() -> None:
26+
# Step 1: Add column as nullable first
27+
op.add_column("simulants", sa.Column("connect_key", sa.String(), nullable = True))
28+
29+
# Step 2: Backfill existing users with unique connect keys
30+
bind = op.get_bind()
31+
session = Session(bind = bind)
32+
33+
# Get all existing users
34+
users = session.execute(sa.text("SELECT id FROM simulants WHERE connect_key IS NULL")).fetchall()
35+
36+
for user_row in users:
37+
user_id = user_row[0]
38+
# Generate unique connect key for each user
39+
connect_key = generate_unique_connect_key(session)
40+
session.execute(
41+
sa.text("UPDATE simulants SET connect_key = :key WHERE id = :id"),
42+
{"key": connect_key, "id": user_id},
43+
)
44+
45+
session.commit()
46+
47+
# Step 3: Make column non-nullable and add unique index
48+
op.alter_column("simulants", "connect_key", nullable = False)
49+
op.create_index(op.f("ix_simulants_connect_key"), "simulants", ["connect_key"], unique = True)
50+
51+
52+
def generate_unique_connect_key(session: Session) -> str:
53+
max_attempts = 100
54+
for attempt in range(max_attempts):
55+
# Generate 12 random characters from allowed charset
56+
key_chars = [secrets.choice(CONNECT_KEY_CHARS) for _ in range(12)]
57+
formatted_key = f"{''.join(key_chars[0:4])}-{''.join(key_chars[4:8])}-{''.join(key_chars[8:12])}"
58+
59+
# Check uniqueness
60+
result = session.execute(
61+
sa.text("SELECT COUNT(*) FROM simulants WHERE connect_key = :key"),
62+
{"key": formatted_key},
63+
).scalar()
64+
65+
if result == 0:
66+
return formatted_key
67+
68+
raise ValueError(f"Failed to generate unique connect key after {max_attempts} attempts")
69+
70+
71+
def downgrade() -> None:
72+
# ### commands auto generated by Alembic - please adjust! ###
73+
op.drop_index(op.f("ix_simulants_connect_key"), table_name = "simulants")
74+
op.drop_column("simulants", "connect_key")
75+
# ### end Alembic commands ###

0 commit comments

Comments
 (0)