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
72 changes: 26 additions & 46 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Upload Python Package
name: Publish

on:
release:
Expand All @@ -8,77 +8,57 @@ on:
permissions:
contents: read

jobs:
release-checks:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- name: Set up uv
uses: astral-sh/setup-uv@v4
with:
python-version: "3.10"
enable-cache: true
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

- name: Check lint
run: uv run ruff check src tests

- name: Check formatting
run: uv run ruff format --check src tests

- name: Run tests
run: uv run pytest

- name: Build docs
run: uv run sphinx-build -b html docs /tmp/pymax-docs

release-build:
jobs:
package:
name: Build package
runs-on: ubuntu-latest
needs: release-checks

steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Checkout repository
uses: actions/checkout@v6

- name: Set up uv
uses: astral-sh/setup-uv@v4
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
Comment on lines +21 to +25
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and show the publish workflow with line numbers
FILE=".github/workflows/publish.yml"
if [ ! -f "$FILE" ]; then
  echo "Missing: $FILE"
  exit 1
fi

echo "---- $FILE (with line numbers) ----"
nl -ba "$FILE" | sed -n '1,200p'
echo "---- End ----"

# Extract all `uses:` lines for quick verification
echo "---- uses: lines ----"
rg -n "^\s*uses:\s*" "$FILE" || true

Repository: MaxApiTeam/PyMax

Length of output: 182


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE=".github/workflows/publish.yml"
if [ ! -f "$FILE" ]; then
  echo "Missing: $FILE"
  exit 1
fi

echo "---- $FILE (with line numbers) ----"
awk '{printf "%d\t%s\n", NR, $0}' "$FILE" | sed -n '1,200p'

echo "---- uses: lines ----"
rg -n "^\s*uses:\s*" "$FILE" || true

Repository: MaxApiTeam/PyMax

Length of output: 1772


🌐 Web query:

GitHub Actions security hardening pin actions to full commit SHA immutable only form movable tags

💡 Result:

Pin GitHub Actions by full-length commit SHA (immutable), not by tags (movable). GitHub’s own guidance says pinning to a full-length commit SHA is currently the only way to treat an action reference as an immutable release, and warns that specifying a tag carries risk because tags can be moved or deleted if an attacker gains access to the action’s repository.[1][2] In a workflow, use this form: - uses: owner/repo@ For example, the docs’ guidance is that the reference should be a full-length commit SHA; the short/abbreviated SHA form is explicitly discouraged as insecure.[3] Also consider enforcing this via GitHub Actions policy: - GitHub introduced an “allowed actions policy” capability to enforce SHA pinning; workflows that attempt to use an action that isn’t pinned to a full commit SHA will fail the check.[4] Why “full commit SHA only” matters (vs tags): - GitHub explains that SHA pinning helps mitigate backdoors because an attacker would need to generate a SHA-1 collision for a valid Git object payload.[1] - GitHub also notes that pinning to tags is less secure and depends on trusting the creator; even then, tags can be moved/deleted.[1][2] Practical hardening tips (beyond pinning): - Audit the action’s source code and behavior with respect to how it handles repository contents and secrets.[2][5]

Citations:


Pin all GitHub Actions uses: references to full commit SHAs in .github/workflows/publish.yml (including the OIDC publish job).

This workflow trusts movable tags for:

  • actions/checkout@v6 (line 22)
  • astral-sh/setup-uv@v8.1.0 (lines 25, 61)
  • actions/upload-artifact@v4 (line 36)
  • actions/download-artifact@v4 (line 55)

Because the publish job has id-token: write, a retagged action could tamper with what uv publish publishes. GitHub guidance recommends full-length commit SHAs for immutable action references. https://docs.github.com/en/actions/reference/security/secure-use

🧰 Tools
🪛 zizmor (1.25.2)

[warning] 21-22: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 22-22: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 25-25: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 25-25: runtime artifacts potentially vulnerable to a cache poisoning attack (cache-poisoning): this step

(cache-poisoning)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/publish.yml around lines 21 - 25, Replace all movable tag
references in the workflow with full commit SHAs: update uses: for
actions/checkout@v6, astral-sh/setup-uv@v8.1.0 (both occurrences),
actions/upload-artifact@v4, and actions/download-artifact@v4 to their
corresponding full-length commit SHAs; ensure the OIDC "publish" job's id-token:
write step also points to the SHA-pinned actions. Locate these by their uses:
lines (e.g., "uses: actions/checkout", "uses: astral-sh/setup-uv", "uses:
actions/upload-artifact", "uses: actions/download-artifact") and replace the tag
suffix (e.g., `@v6`) with the exact commit SHA obtained from each action's repo.

with:
python-version: "3.10"
enable-cache: true

- name: Build release distributions
- name: Build package distributions
run: uv build

- name: Check distributions
- name: Validate package distributions
run: uv run twine check dist/*

- name: Upload distributions
- name: Upload package distributions
uses: actions/upload-artifact@v4
with:
name: release-dists
name: package-distributions
path: dist/

pypi-publish:
publish:
name: Publish to PyPI
runs-on: ubuntu-latest
needs: release-build
needs: [package]

environment:
name: pypi
url: https://pypi.org/project/maxapi-python/

permissions:
contents: read
id-token: write

steps:
- name: Retrieve release distributions
- name: Download package distributions
uses: actions/download-artifact@v4
with:
name: release-dists
name: package-distributions
path: dist/

- name: Publish release distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0

- name: Publish package to PyPI
run: uv publish
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ PyMax - асинхронная Python-библиотека для Max API. Он
:maxdepth: 1
:caption: Новости

release-2-1-2
release-2-1-1
release-2-1-0

Expand Down
32 changes: 32 additions & 0 deletions docs/release-2-1-2.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
PyMax 2.1.2
===========

Изменения относительно ``2.1.1``.

Добавлено
---------

* ``get_bot_init_data()`` теперь можно вызвать без ``chat_id`` для сценариев,
где Max запускает web app вне конкретного чата.

Исправлено
----------

* ``ExtraConfig.request_timeout`` снова применяется к API-запросам по
умолчанию, а явный ``timeout`` в низкоуровневом вызове сохраняет приоритет.
* Login-ответ без нового ``token`` больше не ломает запуск клиента и не
перезаписывает сохраненный токен пустым значением.
* ``FolderList`` больше не переопределяет pydantic-итератор и не ломает
``dict(...)`` / стандартную сериализацию модели.

Изменилось
----------

* Publish workflow упрощен: сборка, проверка дистрибутивов и публикация в PyPI
теперь разделены на понятные шаги с artifact handoff.

Миграция
--------

* Если код итерировал ``FolderList`` напрямую, замените это на
``folder_list.folders``.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "maxapi-python"
version = "2.1.1"
version = "2.1.2"
description = "Python wrapper для API мессенджера Max"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 1 addition & 1 deletion src/pymax/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "2.1.1"
__version__ = "2.1.2"


from .auth import (
Expand Down
2 changes: 1 addition & 1 deletion src/pymax/api/bots/payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@

class RequestInitDataPayload(CamelModel):
bot_id: int
chat_id: int
chat_id: int | None = None
start_param: str | None = None
10 changes: 3 additions & 7 deletions src/pymax/api/bots/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,9 @@ def __init__(self, app: App) -> None:
async def get_init_data(
self,
bot_id: int,
chat_id: int,
chat_id: int | None = None,
start_param: str | None = None,
) -> InitData:
frame = RequestInitDataPayload(
bot_id=bot_id, chat_id=chat_id, start_param=start_param
)
response = await self.app.invoke(
Opcode.WEB_APP_INIT_DATA, frame.to_payload()
)
frame = RequestInitDataPayload(bot_id=bot_id, chat_id=chat_id, start_param=start_param)
response = await self.app.invoke(Opcode.WEB_APP_INIT_DATA, frame.to_payload())
return require_payload_model(response, InitData)
27 changes: 10 additions & 17 deletions src/pymax/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ def __init__(
self.dispatcher: Dispatcher[ClientT] = Dispatcher(self, root_router)
self.api = ApiFacade(self)
self.config = config
self.store = self.config.store or SessionStore(
config.work_dir, config.session_name
)
self.store = self.config.store or SessionStore(config.work_dir, config.session_name)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve injected stores even when they are falsy.

Line 36 now treats any falsy custom store as “missing” and replaces it with a new SessionStore. That breaks the config.store injection contract and can silently switch callers from their own persistence backend to on-disk storage. Use an explicit None check here instead.

Suggested fix
-        self.store = self.config.store or SessionStore(config.work_dir, config.session_name)
+        self.store = (
+            self.config.store
+            if self.config.store is not None
+            else SessionStore(config.work_dir, config.session_name)
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pymax/app.py` at line 36, The constructor currently treats any falsy
config.store as missing by using "self.config.store or SessionStore(...)", which
overwrites intentionally injected falsy stores; change the logic to an explicit
None check so injected stores are preserved: set self.store to config.store if
config.store is not None, otherwise construct a new SessionStore using
SessionStore(config.work_dir, config.session_name); update the assignment for
self.store in the initializer where self.config.store and SessionStore are
referenced.

self.auth_flow = auth_flow

self.me: Profile | None = None
Expand Down Expand Up @@ -76,18 +74,14 @@ async def start(self) -> None:
await self.connection.open()

handshake_device_id = (
session_data.device_id
if session_data
else self.config.device.device_id
session_data.device_id if session_data else self.config.device.device_id
)
logger.debug("running handshake")
await self.handshake(handshake_device_id)
except (ConnectionError, EOFError, OSError, TimeoutError) as e:
logger.exception("failed to connect or handshake")
await self.connection.close()
raise ConnectionError(
f"Failed to connect and handshake: {e}"
) from e
raise ConnectionError(f"Failed to connect and handshake: {e}") from e

self._ping_task = asyncio.create_task(self._ping_loop())

Expand All @@ -108,9 +102,7 @@ async def start(self) -> None:

if not auth_result.token:
logger.error("authentication finished without token")
raise RuntimeError(
"Authentication failed: no token received"
)
raise RuntimeError("Authentication failed: no token received")

await self.store.save_session(
session_data := SessionInfo(
Expand All @@ -135,7 +127,7 @@ async def start(self) -> None:
self.config.device.user_agent,
)

if response.token != self.session.token:
if response.token is not None and response.token != self.session.token:
await self.store.update_token(self.session.token, response.token)
self.session.token = response.token

Expand Down Expand Up @@ -189,7 +181,7 @@ async def invoke(
opcode: int,
payload: dict[str, Any],
cmd: int = Command.REQUEST,
timeout: float | None = 30.0,
timeout: float | None = None,
compress: bool = False,
) -> InboundFrame:
seq = self.connection.next_seq()
Expand All @@ -211,10 +203,11 @@ async def invoke(
payload_keys,
)
logger.debug("Request data=%s", frame.model_dump())
response = await self.connection.request(frame, timeout=timeout)
response_keys = (
sorted(response.payload.keys()) if response.payload else []
request_timeout = (
self.config.request_timeout if timeout is None else timeout
)
response = await self.connection.request(frame, timeout=request_timeout)
response_keys = sorted(response.payload.keys()) if response.payload else []
logger.debug(
"response opcode=%s cmd=%s seq=%s payload_keys=%s",
response.opcode,
Expand Down
6 changes: 0 additions & 6 deletions src/pymax/types/domain/folder.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
from collections.abc import Iterator
from typing import Any

from pydantic import Field
from typing_extensions import override

from .base import CamelModel

Expand Down Expand Up @@ -68,7 +66,3 @@ class FolderList(CamelModel):
folders: list[Folder] = Field(default_factory=list)
all_filter_exclude_folders: list[Any] = Field(default_factory=list)
folder_sync: int = 0

@override
def __iter__(self) -> Iterator[Folder]: # pyright: ignore[reportIncompatibleMethodOverride]
yield from self.folders
26 changes: 7 additions & 19 deletions src/pymax/types/domain/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,9 @@ class LoginConfig(CamelModel):
class LoginResponse(CamelModel):
chats: list[Chat] = Field(default_factory=list)
profile: Profile
messages: dict[int, list[Message]] = Field(
default_factory=dict
) # chat_id -> [message]
messages: dict[int, list[Message]] = Field(default_factory=dict) # chat_id -> [message]
contacts: list[User | None] = Field(default_factory=list)
token: str
token: str | None = None
time: int | None = None
config: LoginConfig | None = None

Expand All @@ -29,19 +27,9 @@ def update_sync_state(self, current: SyncState) -> SyncState:
config_hash = self.config.hash if self.config is not None else None

return SyncState(
chats_sync=(
sync_time if sync_time is not None else current.chats_sync
),
contacts_sync=(
sync_time if sync_time is not None else current.contacts_sync
),
drafts_sync=(
sync_time if sync_time is not None else current.drafts_sync
),
presence_sync=(
sync_time if sync_time is not None else current.presence_sync
),
config_hash=(
config_hash if config_hash is not None else current.config_hash
),
chats_sync=(sync_time if sync_time is not None else current.chats_sync),
contacts_sync=(sync_time if sync_time is not None else current.contacts_sync),
drafts_sync=(sync_time if sync_time is not None else current.drafts_sync),
presence_sync=(sync_time if sync_time is not None else current.presence_sync),
config_hash=(config_hash if config_hash is not None else current.config_hash),
)
2 changes: 1 addition & 1 deletion tests/api/test_chat_user_self_session_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ async def test_self_service_profile_photo_folders_and_logout() -> None:

assert created.folder is not None
assert created.folder.title == "Work"
assert [folder.id for folder in folders] == ["folder-1"]
assert [folder.id for folder in folders.folders] == ["folder-1"]
assert updated.folder is not None
assert updated.folder.title == "New"
assert deleted.folder_sync == 4
Expand Down
18 changes: 18 additions & 0 deletions tests/app/test_app_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,21 @@ async def test_app_invoke_turns_error_frames_into_api_error() -> None:
assert exc_info.value.error == "rate_limited"
assert exc_info.value.opcode == Opcode.PING
assert connection.sent[0][0].seq == 0


@pytest.mark.asyncio
async def test_app_invoke_uses_config_timeout_and_allows_override() -> None:
store = RuntimeStore(
SessionInfo(token="token", device_id="dev", phone="+7")
)
config = make_config().model_copy(
update={"request_timeout": 12.5, "store": store}
)
connection = RuntimeConnection([frame({}), frame({})])
app: App[object] = App(connection, config, StaticAuthFlow())

await app.invoke(Opcode.PING, {"interactive": True})
await app.invoke(Opcode.PING, {"interactive": True}, timeout=3.0)

assert connection.sent[0][1] == 12.5
assert connection.sent[1][1] == 3.0
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.