Skip to content

Tentacle interfaces

The base classes every tentacle extends, and the pieces a channel is built from. The tentacle model explains the hierarchy; Contributing shows how to add one.

Reference pages

NapCat

octomate.tentacles.napcat.schema

NapCat wire shapes: OneBot frames, segments and senders, and their decoding.

NapcatMessageSegment module-attribute

NapcatMessageSegment = Annotated[
    Annotated[TextSegment, Tag("text")]
    | Annotated[NapcatAtSegment, Tag("at")]
    | Annotated[ImageSegment, Tag("image")]
    | Annotated[ReplySegment, Tag("reply")]
    | Annotated[FaceSegment, Tag("face")]
    | Annotated[RecordSegment, Tag("record")]
    | Annotated[VideoSegment, Tag("video")]
    | Annotated[GenericSegment, Tag("__generic__")],
    Discriminator(segment_discriminator),
]

InboundFrame module-attribute

InboundFrame = Annotated[
    Annotated[NapcatMessageEvent, Tag("message")]
    | Annotated[NapcatMetaEvent, Tag("meta_event")]
    | Annotated[
        ActionResponse[JsonObject], Tag("response")
    ],
    Discriminator(_inbound_discriminator),
]

inbound_adapter module-attribute

inbound_adapter = TypeAdapter(InboundFrame)

COMPATIBLE_SEGMENTS module-attribute

COMPATIBLE_SEGMENTS = (
    TextSegment,
    AtSegment,
    ImageSegment,
    ReplySegment,
)

NapcatOutboundMessage dataclass

One OneBot message: its segment array.

Source code in octomate/tentacles/napcat/schema.py
@dataclass(frozen=True)
class NapcatOutboundMessage:
    """One OneBot message: its segment array."""

    segments: list[JsonObject]

segments instance-attribute

segments: list[JsonObject]

ActionResponse pydantic-model

Bases: BaseModel

A typed OneBot action response from HTTP or the event socket.

Config:

  • extra: ignore
  • coerce_numbers_to_str: True

Fields:

Source code in octomate/tentacles/napcat/schema.py
class ActionResponse[DataT](BaseModel):
    """A typed OneBot action response from HTTP or the event socket."""

    model_config = ConfigDict(extra="ignore", coerce_numbers_to_str=True)

    status: str = ""
    retcode: int = 0
    data: DataT | None = None
    echo: str | None = None
    message: str | None = None
    wording: str | None = None

model_config class-attribute instance-attribute

model_config = ConfigDict(
    extra="ignore", coerce_numbers_to_str=True
)

status pydantic-field

status: str = ''

retcode pydantic-field

retcode: int = 0

data pydantic-field

data: DataT | None = None

echo pydantic-field

echo: str | None = None

message pydantic-field

message: str | None = None

wording pydantic-field

wording: str | None = None

ImageInfo pydantic-model

Bases: BaseModel

Fields:

  • url (str | None)
Source code in octomate/tentacles/napcat/schema.py
class ImageInfo(BaseModel):
    url: str | None = None

url pydantic-field

url: str | None = None

GroupInfo pydantic-model

Bases: BaseModel

Fields:

Source code in octomate/tentacles/napcat/schema.py
class GroupInfo(BaseModel):
    group_name: str

group_name pydantic-field

group_name: str

SentMessage pydantic-model

Bases: BaseModel

Config:

  • coerce_numbers_to_str: True

Fields:

Source code in octomate/tentacles/napcat/schema.py
class SentMessage(BaseModel):
    model_config = ConfigDict(coerce_numbers_to_str=True)

    message_id: str | None = None

model_config class-attribute instance-attribute

model_config = ConfigDict(coerce_numbers_to_str=True)

message_id pydantic-field

message_id: str | None = None

NapcatUserProfile

Bases: UserProfile

A QQ user as get_stranger_info reports them, normalized onto UserProfile.

Source code in octomate/tentacles/napcat/schema.py
class NapcatUserProfile(UserProfile):
    """A QQ user as `get_stranger_info` reports them, normalized onto `UserProfile`."""

    model_config = ConfigDict(
        alias_generator=AliasGenerator(validation_alias=to_camel),
        validate_by_alias=True,
        validate_by_name=True,
        extra="ignore",
        coerce_numbers_to_str=True,
        frozen=True,
    )

    name: str = Field(default="", validation_alias="nick")
    nickname: str | None = Field(default=None)
    gender: str | None = Field(default=None, validation_alias="sex")

    uid: str = ""
    qid: str = ""
    uin: str = ""
    long_nick: str = ""
    remark: str = ""

    @model_validator(mode="before")
    @classmethod
    def normalize(cls, data: JsonObject) -> JsonObject:
        if isinstance(data, dict):
            # `user_id` is popped, not kept: on the registry schema that name is
            # the owner FK, and the payload's value is the platform id.
            data["channel_user_id"] = (
                data.get("channel_user_id")
                or data.pop("user_id", None)
                or data.get("uin")
                or ""
            )
            if not data.get("nick") and not data.get("name"):
                data["name"] = (
                    data.get("card")
                    or data.get("nickname")
                    or data.get("remark")
                    or data.get("channel_user_id")
                    or ""
                )
            data.setdefault("nickname", data.get("nickname") or data.get("card"))
        return data

model_config class-attribute instance-attribute

model_config = ConfigDict(
    alias_generator=AliasGenerator(
        validation_alias=to_camel
    ),
    validate_by_alias=True,
    validate_by_name=True,
    extra="ignore",
    coerce_numbers_to_str=True,
    frozen=True,
)

name class-attribute instance-attribute

name: str = Field(default='', validation_alias='nick')

nickname class-attribute instance-attribute

nickname: str | None = Field(default=None)

gender class-attribute instance-attribute

gender: str | None = Field(
    default=None, validation_alias="sex"
)

uid class-attribute instance-attribute

uid: str = ''

qid class-attribute instance-attribute

qid: str = ''

uin class-attribute instance-attribute

uin: str = ''

long_nick class-attribute instance-attribute

long_nick: str = ''

remark class-attribute instance-attribute

remark: str = ''

normalize classmethod

normalize(data: JsonObject) -> JsonObject
Source code in octomate/tentacles/napcat/schema.py
@model_validator(mode="before")
@classmethod
def normalize(cls, data: JsonObject) -> JsonObject:
    if isinstance(data, dict):
        # `user_id` is popped, not kept: on the registry schema that name is
        # the owner FK, and the payload's value is the platform id.
        data["channel_user_id"] = (
            data.get("channel_user_id")
            or data.pop("user_id", None)
            or data.get("uin")
            or ""
        )
        if not data.get("nick") and not data.get("name"):
            data["name"] = (
                data.get("card")
                or data.get("nickname")
                or data.get("remark")
                or data.get("channel_user_id")
                or ""
            )
        data.setdefault("nickname", data.get("nickname") or data.get("card"))
    return data

NapcatSender

Bases: UserProfile

The sender block of a OneBot message event.

Source code in octomate/tentacles/napcat/schema.py
class NapcatSender(UserProfile):
    """The `sender` block of a OneBot message event."""

    model_config = ConfigDict(extra="ignore", coerce_numbers_to_str=True)

    nickname: str | None = None
    card: str = ""
    role: str = ""

    @model_validator(mode="before")
    @classmethod
    def normalize(cls, data: JsonObject) -> JsonObject:
        if isinstance(data, dict):
            # OneBot sender payloads carry the platform id as `user_id`; pop it
            # into channel_user_id so the owner-FK field never sees it.
            if "channel_user_id" not in data and (uid := data.pop("user_id", None)):
                data["channel_user_id"] = uid
            if not data.get("name"):
                data["name"] = data.get("card") or data.get("nickname") or ""
        return data

model_config class-attribute instance-attribute

model_config = ConfigDict(
    extra="ignore", coerce_numbers_to_str=True
)

nickname class-attribute instance-attribute

nickname: str | None = None

card class-attribute instance-attribute

card: str = ''

role class-attribute instance-attribute

role: str = ''

normalize classmethod

normalize(data: JsonObject) -> JsonObject
Source code in octomate/tentacles/napcat/schema.py
@model_validator(mode="before")
@classmethod
def normalize(cls, data: JsonObject) -> JsonObject:
    if isinstance(data, dict):
        # OneBot sender payloads carry the platform id as `user_id`; pop it
        # into channel_user_id so the owner-FK field never sees it.
        if "channel_user_id" not in data and (uid := data.pop("user_id", None)):
            data["channel_user_id"] = uid
        if not data.get("name"):
            data["name"] = data.get("card") or data.get("nickname") or ""
    return data

NapcatAtData pydantic-model

Bases: AtData

AtData read from OneBot's qq key.

Config:

  • populate_by_name: True

Fields:

Source code in octomate/tentacles/napcat/schema.py
class NapcatAtData(AtData):
    """`AtData` read from OneBot's `qq` key."""

    model_config = ConfigDict(populate_by_name=True)

    user_id: str = Field(validation_alias="qq")

model_config class-attribute instance-attribute

model_config = ConfigDict(populate_by_name=True)

user_id pydantic-field

user_id: str

NapcatAtSegment pydantic-model

Bases: Segment

OneBot's at segment, whose target is qq.

Fields:

Source code in octomate/tentacles/napcat/schema.py
class NapcatAtSegment(Segment):
    """OneBot's `at` segment, whose target is `qq`."""

    type: Literal["at"] = "at"
    data: NapcatAtData

    def __str__(self) -> str:
        return f"@{self.data.name or self.data.user_id}"

type pydantic-field

type: Literal['at'] = 'at'

data pydantic-field

data: NapcatAtData

FaceData

Bases: TypedDict

Source code in octomate/tentacles/napcat/schema.py
class FaceData(TypedDict):
    id: str

id instance-attribute

id: str

RecordData

Bases: TypedDict

Source code in octomate/tentacles/napcat/schema.py
class RecordData(TypedDict):
    file: str
    url: NotRequired[str | None]

file instance-attribute

file: str

url instance-attribute

url: NotRequired[str | None]

VideoData

Bases: TypedDict

Source code in octomate/tentacles/napcat/schema.py
class VideoData(TypedDict):
    file: str
    url: NotRequired[str | None]

file instance-attribute

file: str

url instance-attribute

url: NotRequired[str | None]

GenericSegment pydantic-model

Bases: Segment

Any segment type this channel does not model.

Fields:

Source code in octomate/tentacles/napcat/schema.py
class GenericSegment(Segment):
    """Any segment type this channel does not model."""

    type: str
    data: JsonObject = Field(default_factory=dict)

type pydantic-field

type: str

data pydantic-field

data: JsonObject

FaceSegment pydantic-model

Bases: Segment

OneBot's face segment.

Fields:

Source code in octomate/tentacles/napcat/schema.py
class FaceSegment(Segment):
    """OneBot's `face` segment."""

    type: Literal["face"] = "face"
    data: FaceData

type pydantic-field

type: Literal['face'] = 'face'

data pydantic-field

data: FaceData

RecordSegment pydantic-model

Bases: Segment

OneBot's record (voice) segment.

Fields:

Source code in octomate/tentacles/napcat/schema.py
class RecordSegment(Segment):
    """OneBot's `record` (voice) segment."""

    type: Literal["record"] = "record"
    data: RecordData

type pydantic-field

type: Literal['record'] = 'record'

data pydantic-field

data: RecordData

VideoSegment pydantic-model

Bases: Segment

OneBot's video segment.

Fields:

Source code in octomate/tentacles/napcat/schema.py
class VideoSegment(Segment):
    """OneBot's `video` segment."""

    type: Literal["video"] = "video"
    data: VideoData

type pydantic-field

type: Literal['video'] = 'video'

data pydantic-field

data: VideoData

NapcatMessageEvent pydantic-model

Bases: BaseModel

A OneBot message event, private or group.

Config:

  • extra: ignore
  • coerce_numbers_to_str: True

Fields:

Source code in octomate/tentacles/napcat/schema.py
class NapcatMessageEvent(BaseModel):
    """A OneBot `message` event, private or group."""

    model_config = ConfigDict(extra="ignore", coerce_numbers_to_str=True)

    post_type: Literal["message"] = "message"
    message_type: Literal["private", "group"]
    time: int = 0
    self_id: str = ""
    message_id: str
    user_id: str
    group_id: str = ""
    group_name: str | None = Field(
        default=None,
        description="The group name supplied by NapCat, when available.",
    )
    sender: NapcatSender = Field(default_factory=NapcatSender)
    message: list[NapcatMessageSegment] = Field(default_factory=list)
    raw_message: str = ""

model_config class-attribute instance-attribute

model_config = ConfigDict(
    extra="ignore", coerce_numbers_to_str=True
)

post_type pydantic-field

post_type: Literal['message'] = 'message'

message_type pydantic-field

message_type: Literal['private', 'group']

time pydantic-field

time: int = 0

self_id pydantic-field

self_id: str = ''

message_id pydantic-field

message_id: str

user_id pydantic-field

user_id: str

group_id pydantic-field

group_id: str = ''

group_name pydantic-field

group_name: str | None = None

The group name supplied by NapCat, when available.

sender pydantic-field

sender: NapcatSender

message pydantic-field

message: list[NapcatMessageSegment]

raw_message pydantic-field

raw_message: str = ''

NapcatMetaEvent pydantic-model

Bases: BaseModel

A OneBot heartbeat or lifecycle notification.

The message pipeline only needs this envelope to discard connection-status notifications; their event-specific fields are ignored.

Fields:

Source code in octomate/tentacles/napcat/schema.py
class NapcatMetaEvent(BaseModel):
    """A OneBot heartbeat or lifecycle notification.

    The message pipeline only needs this envelope to discard connection-status
    notifications; their event-specific fields are ignored.
    """

    post_type: Literal["meta_event"] = "meta_event"
    meta_event_type: Literal["heartbeat", "lifecycle"]

post_type pydantic-field

post_type: Literal['meta_event'] = 'meta_event'

meta_event_type pydantic-field

meta_event_type: Literal['heartbeat', 'lifecycle']

segment_discriminator

segment_discriminator(raw: JsonObject | Segment) -> str
Source code in octomate/tentacles/napcat/schema.py
def segment_discriminator(raw: JsonObject | Segment) -> str:
    if isinstance(raw, dict):
        segment_type = raw.get("type", "")
    else:
        segment_type = getattr(raw, "type", "")
    if segment_type in {"text", "at", "image", "reply", "face", "record", "video"}:
        return segment_type
    return "__generic__"

to_message_event

to_message_event(event: NapcatMessageEvent) -> MessageEvent
Source code in octomate/tentacles/napcat/schema.py
def to_message_event(event: NapcatMessageEvent) -> MessageEvent:
    segments: list[MessageSegment] = []
    for seg in event.message:
        if isinstance(seg, NapcatAtSegment):
            segments.append(
                AtSegment(data=AtData(user_id=seg.data.user_id, name=seg.data.name))
            )
        elif isinstance(seg, COMPATIBLE_SEGMENTS):
            segments.append(seg)
    reply_id = next(
        (
            str(seg.data["id"])
            for seg in segments
            if isinstance(seg, ReplySegment) and seg.data.get("id")
        ),
        "",
    )
    # `message_type` is OneBot's own wire value and stays "private"; ours is "dm".
    chat_type: ChatType = "group" if event.message_type == "group" else "dm"
    chat_id = event.group_id if chat_type == "group" else event.user_id
    return MessageEvent(
        message_id=event.message_id,
        reply_id=reply_id,
        timestamp=float(event.time),
        user_id=event.user_id,
        chat_id=chat_id,
        chat_name=event.group_name if chat_type == "group" else None,
        chat_type=chat_type,
        shared=chat_type == "group",
        self_id=event.self_id,
        sender=event.sender,
        segments=segments,
        raw=event.raw_message,
    )