Pārlūkot izejas kodu

feat: add channel management and scheduled publishing

AI-Co-Authored-By: Codex
chendeben 6 dienas atpakaļ
vecāks
revīzija
a79925a3e8

+ 14 - 0
README.md

@@ -51,6 +51,20 @@ API Key 和模型,再为每个连接账号分别配置知识库与通知目标
 Telegram 原生欢迎语/离线消息继续在账号侧配置;智能接待会忽略这些消息以及
 Bot 自己发送的消息,并在官方 24 小时回复窗口外停止发送、通知人工。
 
+## 频道管理
+
+后台“频道管理”独立于群组管理。先将对应 Bot 设为频道管理员,再等待频道更新
+自动登记,或在后台输入频道数字 ID 接入。按需授予 Bot 发布、编辑、删除帖子、
+修改频道资料、管理邀请链接和管理员等 Telegram 权限;后台角色还需具备相应的
+`channel.*` 职责权限。上传媒体需要 `media.upload` 权限和媒体中转群配置。
+公开频道若尚未进入 Bot 的 Telegram 本地缓存,手动接入会通过频道用户名补全;
+私有频道没有用户名时,需等待 Bot 收到该频道的新帖后再通过 ID 接入。
+
+管理员可以维护频道名称、简介、头像、邀请链接和管理员,通过后台发布文字或
+单个媒体帖子,或设置一次性定时发布。发布前可修改或取消定时任务。帖子页记录
+Bot 接入后观察到的新帖;编辑和删除会同步到 Telegram。定时发送若结果不明,
+任务会停在“待核实”,不会自动重发。旧帖和 Bot 离线期间的消息无法保证补齐。
+
 <p align="center">
     <a href="https://python.org">
         <img src="http://forthebadge.com/images/badges/made-with-python.svg" alt="made-with-python">

+ 2 - 0
admin-web/.umirc.ts

@@ -27,6 +27,8 @@ export default defineConfig({
         { path: '/dashboard', component: '@/pages/Dashboard' },
         { path: '/chats', component: '@/pages/Chats' },
         { path: '/chats/:chatId', component: '@/pages/ChatDetail' },
+        { path: '/channels', component: '@/pages/Channels' },
+        { path: '/channels/:chatId', component: '@/pages/ChannelDetail' },
         { path: '/giveaways', component: '@/pages/Giveaways' },
         { path: '/points', component: '@/pages/Points' },
         { path: '/directory', component: '@/pages/Directory' },

+ 7 - 1
admin-web/src/layouts/AdminLayout.tsx

@@ -4,6 +4,7 @@ import type { MenuProps } from 'antd';
 import {
   Bot,
   BotMessageSquare,
+  RadioTower,
   ChevronDown,
   ClipboardList,
   Command,
@@ -33,6 +34,7 @@ import type { AdminUser, BotProfile } from '@/types';
 const menuItems = [
   { path: '/dashboard', name: '概览', icon: <LayoutDashboard size={18} /> },
   { path: '/chats', name: '群组管理', icon: <Users size={18} /> },
+  { path: '/channels', name: '频道管理', icon: <RadioTower size={18} /> },
   { path: '/giveaways', name: '抽奖管理', icon: <Gift size={18} /> },
   { path: '/points', name: '积分管理', icon: <Star size={18} /> },
   { path: '/directory', name: '技师与附近', icon: <MapPinned size={18} /> },
@@ -116,8 +118,10 @@ export default function AdminLayout() {
     'chat.notes',
     'automation.manage',
   ];
+  const channelPermissions = ['channel.profile', 'channel.admins', 'channel.invites', 'channel.posts'];
   const visibleMenuItems = menuItems.filter((item) => {
     if (item.path === '/chats') return hasAnyPermission(chatPermissions);
+    if (item.path === '/channels') return hasAnyPermission(channelPermissions);
     if (item.path === '/points') return hasPermission('points.manage');
     if (item.path === '/giveaways') return hasPermission('giveaways.manage');
     if (item.path === '/directory' || item.path === '/services') {
@@ -143,7 +147,9 @@ export default function AdminLayout() {
       (location.pathname.startsWith('/business-assistant') &&
         !hasPermission('business_assistant.manage')) ||
       (location.pathname.startsWith('/chats') &&
-        !hasAnyPermission(chatPermissions));
+        !hasAnyPermission(chatPermissions)) ||
+      (location.pathname.startsWith('/channels') &&
+        !hasAnyPermission(channelPermissions));
     if (denied) history.replace('/dashboard');
   }, [selectedBot, bots, location.pathname]);
 

+ 328 - 0
admin-web/src/pages/ChannelDetail.tsx

@@ -0,0 +1,328 @@
+import {
+  Alert, Button, Checkbox, DatePicker, Descriptions, Form, Image, Input, InputNumber,
+  Modal, Popconfirm, Select, Space, Spin, Table, Tabs, Tag, Typography, Upload, message,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import dayjs, { type Dayjs } from 'dayjs';
+import { history, useParams } from '@umijs/max';
+import { ArrowLeft, FileUp, Plus, RefreshCw } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+import { PageHeader, Surface } from '@/components/Page';
+import { useBotAccess } from '@/contexts/BotAccess';
+import { apiRequest, jsonOptions } from '@/services/api';
+import type { ChannelOverview, ChannelPost, ChatMember, Paged } from '@/types';
+import { userDisplayName } from '@/utils/users';
+
+interface InviteLink {
+  invite_link: string;
+  name?: string;
+  expires_at?: string | null;
+  member_limit?: number | null;
+  revoked?: boolean;
+}
+
+interface UploadedMedia {
+  file_id: string;
+  type: string;
+  filename: string;
+  size: number;
+  mime_type: string;
+}
+
+const privilegeOptions = [
+  { label: '发布帖子', value: 'can_post_messages' },
+  { label: '编辑帖子', value: 'can_edit_messages' },
+  { label: '删除帖子', value: 'can_delete_messages' },
+  { label: '修改频道资料', value: 'can_change_info' },
+  { label: '管理邀请链接', value: 'can_invite_users' },
+  { label: '管理管理员', value: 'can_promote_members' },
+  { label: '管理视频聊天', value: 'can_manage_video_chats' },
+];
+
+const statusLabels: Record<ChannelPost['status'], string> = {
+  scheduled: '待发布', sending: '发送中', published: '已发布', uncertain: '待核实',
+  failed: '失败', canceled: '已取消', deleted: '已删除',
+};
+
+function errorText(error: unknown): string {
+  return error instanceof Error ? error.message : '操作失败';
+}
+
+async function uploadMedia(file: File): Promise<UploadedMedia> {
+  const body = new FormData();
+  body.append('file', file);
+  return apiRequest<UploadedMedia>('/media', { method: 'POST', body });
+}
+
+export default function ChannelDetailPage() {
+  const { chatId = '' } = useParams<{ chatId: string }>();
+  const { hasPermission } = useBotAccess();
+  const [overview, setOverview] = useState<ChannelOverview | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  const load = async () => {
+    setLoading(true);
+    try {
+      setOverview(await apiRequest<ChannelOverview>(`/channels/${chatId}`));
+    } catch (error) {
+      message.error(errorText(error));
+    } finally {
+      setLoading(false);
+    }
+  };
+  useEffect(() => {
+    setOverview(null);
+    void load();
+  }, [chatId]);
+  useEffect(() => {
+    const leaveOldBot = () => history.replace('/channels');
+    window.addEventListener('bot-selection-changed', leaveOldBot);
+    return () => window.removeEventListener('bot-selection-changed', leaveOldBot);
+  }, []);
+
+  if (!overview) return <Spin spinning={loading} fullscreen />;
+  const items = [
+    ...(hasPermission('channel.profile') || hasPermission('channel.invites')
+      ? [{ key: 'profile', label: '资料与邀请', children: <ChannelProfile chatId={chatId} overview={overview} onChanged={load} /> }]
+      : []),
+    ...(hasPermission('channel.admins')
+      ? [{ key: 'admins', label: '管理员', children: <ChannelAdmins chatId={chatId} /> }]
+      : []),
+    ...(hasPermission('channel.posts')
+      ? [{ key: 'posts', label: '帖子与定时', children: <ChannelPosts chatId={chatId} /> }]
+      : []),
+  ];
+  return <>
+    <PageHeader title={overview.title} actions={<Space>
+      <Button icon={<ArrowLeft size={16} />} onClick={() => history.push('/channels')}>返回</Button>
+      <Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void load()}>刷新</Button>
+    </Space>} />
+    <Alert type="info" showIcon message="帖子记录从 Bot 接入并收到频道更新后开始;旧帖和离线期间的帖子可能不完整。" style={{ marginBottom: 16 }} />
+    <Tabs items={items} />
+  </>;
+}
+
+function ChannelProfile({ chatId, overview, onChanged }: { chatId: string; overview: ChannelOverview; onChanged: () => Promise<void> }) {
+  const { hasPermission } = useBotAccess();
+  const [form] = Form.useForm();
+  const [inviteForm] = Form.useForm();
+  const [invites, setInvites] = useState<InviteLink[]>([]);
+  const [inviteOpen, setInviteOpen] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const canProfile = hasPermission('channel.profile');
+  const canInvites = hasPermission('channel.invites');
+
+  const loadInvites = async () => {
+    if (!canInvites) return;
+    try {
+      const result = await apiRequest<{ items: InviteLink[] }>(`/channels/${chatId}/invites`);
+      setInvites(result.items);
+    } catch (error) { message.error(errorText(error)); }
+  };
+  useEffect(() => {
+    form.setFieldsValue({ title: overview.title, description: overview.description });
+    void loadInvites();
+  }, [chatId, overview.title, overview.description]);
+
+  const saveProfile = async (values: { title: string; description?: string }) => {
+    setSaving(true);
+    try {
+      await apiRequest(`/channels/${chatId}/profile`, jsonOptions('PATCH', { ...values, confirm: true }));
+      message.success('频道资料已更新');
+      await onChanged();
+    } catch (error) { message.error(errorText(error)); }
+    finally { setSaving(false); }
+  };
+
+  const uploadPhoto = async (file: File) => {
+    const body = new FormData();
+    body.append('photo', file);
+    try {
+      await apiRequest(`/channels/${chatId}/photo`, { method: 'POST', body });
+      message.success('频道头像已更新');
+      await onChanged();
+    } catch (error) { message.error(errorText(error)); }
+  };
+
+  return <div className="split-grid">
+    <Surface title="频道资料">
+      <Space align="start" style={{ marginBottom: 20 }}>
+        {overview.photo_data_url ? <Image src={overview.photo_data_url} width={72} height={72} style={{ borderRadius: 16, objectFit: 'cover' }} /> : <div style={{ width: 72, height: 72, borderRadius: 16, background: '#e7edf3' }} />}
+        {canProfile && <Upload showUploadList={false} accept="image/jpeg,image/png" beforeUpload={(file) => { Modal.confirm({ title: '确认更新频道头像?', okText: '确认更新', onOk: () => uploadPhoto(file) }); return false; }}><Button icon={<FileUp size={16} />}>更换头像</Button></Upload>}
+      </Space>
+      <Descriptions column={1} size="small" bordered style={{ marginBottom: 20 }}>
+        <Descriptions.Item label="频道 ID">{overview.chat_id}</Descriptions.Item>
+        <Descriptions.Item label="用户名">{overview.username ? `@${overview.username}` : '-'}</Descriptions.Item>
+        <Descriptions.Item label="订阅人数">{overview.member_count ?? '-'}</Descriptions.Item>
+        <Descriptions.Item label="Bot 权限">{overview.bot_privileges?.join('、') || '无管理权限'}</Descriptions.Item>
+      </Descriptions>
+      {canProfile && <Form form={form} layout="vertical" onFinish={(values) => Modal.confirm({ title: '确认更新频道资料?', okText: '确认更新', onOk: () => saveProfile(values) })}>
+        <Form.Item name="title" label="频道名称" rules={[{ required: true }]}><Input maxLength={128} /></Form.Item>
+        <Form.Item name="description" label="频道简介"><Input.TextArea rows={4} maxLength={255} showCount /></Form.Item>
+        <Button type="primary" htmlType="submit" loading={saving}>保存资料</Button>
+      </Form>}
+    </Surface>
+    {canInvites && <Surface title="邀请链接" actions={<Button icon={<Plus size={16} />} onClick={() => setInviteOpen(true)}>创建</Button>}>
+      <Table rowKey="invite_link" size="small" dataSource={invites} pagination={false} columns={[
+        { title: '名称', dataIndex: 'name', render: (value) => value || '-' },
+        { title: '链接', dataIndex: 'invite_link', render: (value) => <Typography.Text copyable={{ text: value }} ellipsis style={{ maxWidth: 180 }}>{value}</Typography.Text> },
+        { title: '状态', render: (_, item) => <Tag color={item.revoked ? 'default' : 'success'}>{item.revoked ? '已撤销' : '有效'}</Tag> },
+        { title: '操作', render: (_, item) => item.revoked ? null : <Popconfirm title="撤销这个邀请链接?" onConfirm={async () => { try { await apiRequest(`/channels/${chatId}/invites`, jsonOptions('DELETE', { invite_link: item.invite_link, confirm: true })); await loadInvites(); } catch (error) { message.error(errorText(error)); } }}><Button danger type="link">撤销</Button></Popconfirm> },
+      ]} />
+      <Modal title="创建邀请链接" open={inviteOpen} okText="创建" onOk={() => inviteForm.submit()} onCancel={() => setInviteOpen(false)}>
+        <Form form={inviteForm} layout="vertical" onFinish={async (values) => { try { await apiRequest(`/channels/${chatId}/invites`, jsonOptions('POST', { ...values, expires_at: values.expires_at?.toISOString(), confirm: true })); message.success('邀请链接已创建'); setInviteOpen(false); inviteForm.resetFields(); await loadInvites(); } catch (error) { message.error(errorText(error)); } }}>
+          <Form.Item name="name" label="名称"><Input maxLength={32} /></Form.Item>
+          <Form.Item name="expires_at" label="过期时间"><DatePicker showTime style={{ width: '100%' }} /></Form.Item>
+          <Form.Item name="member_limit" label="人数上限"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
+        </Form>
+      </Modal>
+    </Surface>}
+  </div>;
+}
+
+function ChannelAdmins({ chatId }: { chatId: string }) {
+  const [admins, setAdmins] = useState<ChatMember[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [open, setOpen] = useState(false);
+  const [form] = Form.useForm();
+  const load = async () => {
+    setLoading(true);
+    try { setAdmins((await apiRequest<{ items: ChatMember[] }>(`/channels/${chatId}/admins`)).items); }
+    catch (error) { message.error(errorText(error)); }
+    finally { setLoading(false); }
+  };
+  useEffect(() => { void load(); }, [chatId]);
+  const submit = async (values: { user_id: string; privileges: string[] }) => {
+    try {
+      await apiRequest(`/channels/${chatId}/admins/${values.user_id}`, jsonOptions('PUT', { privileges: values.privileges, confirm: true }));
+      message.success('管理员已更新');
+      setOpen(false);
+      form.resetFields();
+      await load();
+    } catch (error) { message.error(errorText(error)); }
+  };
+  const columns: ColumnsType<ChatMember> = [
+    { title: '用户', render: (_, item) => userDisplayName(item.user) },
+    { title: '用户 ID', render: (_, item) => item.user.id },
+    { title: '身份', render: (_, item) => item.status === 'owner' ? '所有者' : '管理员' },
+    { title: '权限', render: (_, item) => item.privileges.join('、') || '-' },
+    { title: '操作', render: (_, item) => item.status === 'owner' ? null : <Space>
+      <Button type="link" onClick={() => { form.setFieldsValue({ user_id: item.user.id, privileges: item.privileges }); setOpen(true); }}>调整</Button>
+      <Popconfirm title="移除该频道管理员?" onConfirm={async () => { try { await apiRequest(`/channels/${chatId}/admins/${item.user.id}`, jsonOptions('DELETE', { confirm: true })); message.success('已移除管理员'); await load(); } catch (error) { message.error(errorText(error)); } }}><Button danger type="link">移除</Button></Popconfirm>
+    </Space> },
+  ];
+  return <Surface title="频道管理员" actions={<Space><Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void load()}>刷新</Button><Button type="primary" icon={<Plus size={16} />} onClick={() => { form.resetFields(); setOpen(true); }}>添加管理员</Button></Space>}>
+    <Alert type="info" showIcon message="只能通过用户 ID 操作已订阅频道的用户,无法浏览全部订阅者。" style={{ marginBottom: 16 }} />
+    <Table rowKey={(item) => item.user.id} columns={columns} dataSource={admins} loading={loading} pagination={false} scroll={{ x: 800 }} />
+    <Modal title="添加或调整管理员" open={open} okText="确认调整" onOk={() => form.submit()} onCancel={() => setOpen(false)}>
+      <Form form={form} layout="vertical" onFinish={(values) => Modal.confirm({ title: '确认修改频道管理员权限?', okText: '确认', onOk: () => submit(values) })}>
+        <Form.Item name="user_id" label="用户 ID" rules={[{ required: true, pattern: /^\d+$/, message: '请输入用户数字 ID' }]}><Input /></Form.Item>
+        <Form.Item name="privileges" label="管理员权限" rules={[{ required: true, message: '至少选择一项权限' }]}><Checkbox.Group options={privilegeOptions} /></Form.Item>
+      </Form>
+    </Modal>
+  </Surface>;
+}
+
+function ChannelPosts({ chatId }: { chatId: string }) {
+  const { hasPermission } = useBotAccess();
+  const [posts, setPosts] = useState<Paged<ChannelPost>>({ items: [], total: 0, page: 1, page_size: 20 });
+  const [loading, setLoading] = useState(false);
+  const [form] = Form.useForm();
+  const [editForm] = Form.useForm();
+  const [media, setMedia] = useState<UploadedMedia | null>(null);
+  const [editMedia, setEditMedia] = useState<UploadedMedia | null>(null);
+  const [editing, setEditing] = useState<ChannelPost | null>(null);
+  const [uploading, setUploading] = useState(false);
+
+  const load = async (page = 1) => {
+    setLoading(true);
+    try {
+      setPosts(await apiRequest<Paged<ChannelPost>>(`/channels/${chatId}/posts?page=${page}&page_size=20`));
+    } catch (error) { message.error(errorText(error)); }
+    finally { setLoading(false); }
+  };
+  useEffect(() => { void load(); }, [chatId]);
+
+  const mediaButton = (onMedia: (value: UploadedMedia) => void) => <Upload
+    showUploadList={false}
+    disabled={!hasPermission('media.upload')}
+    beforeUpload={(file) => {
+      setUploading(true);
+      uploadMedia(file).then(onMedia).catch((error) => message.error(errorText(error))).finally(() => setUploading(false));
+      return false;
+    }}
+  ><Button icon={<FileUp size={16} />} loading={uploading}>上传单个媒体</Button></Upload>;
+
+  const create = async (values: { text?: string; publish_at?: Dayjs; pin?: boolean }) => {
+    try {
+      const result = await apiRequest<ChannelPost>(`/channels/${chatId}/posts`, jsonOptions('POST', {
+        text: values.text || '', media_type: media?.type, file_id: media?.file_id,
+        media_filename: media?.filename, media_size: media?.size, media_mime_type: media?.mime_type,
+        publish_at: values.publish_at?.toISOString(), pin: Boolean(values.pin), confirm: true,
+      }));
+      message.success(result.status === 'scheduled' ? '已安排定时发布' : '已发布到频道');
+      form.resetFields();
+      setMedia(null);
+      await load();
+    } catch (error) { message.error(errorText(error)); await load(); }
+  };
+
+  const openEdit = (post: ChannelPost) => {
+    setEditing(post);
+    setEditMedia(null);
+    editForm.setFieldsValue({ text: post.text, publish_at: post.status === 'scheduled' && post.publish_at ? dayjs(post.publish_at) : undefined, pin: post.pin });
+  };
+
+  const saveEdit = async (values: { text?: string; publish_at?: Dayjs; pin?: boolean }) => {
+    if (!editing) return;
+    try {
+      await apiRequest(`/channels/${chatId}/posts/${editing.post_id}`, jsonOptions('PATCH', {
+        text: values.text || '',
+        ...(editMedia ? {
+          media_type: editMedia.type, file_id: editMedia.file_id,
+          media_filename: editMedia.filename, media_size: editMedia.size, media_mime_type: editMedia.mime_type,
+        } : {}),
+        ...(editing.status === 'scheduled' ? { publish_at: values.publish_at?.toISOString(), pin: Boolean(values.pin) } : {}),
+        confirm: true,
+      }));
+      message.success('帖子已更新');
+      setEditing(null);
+      await load(posts.page);
+    } catch (error) { message.error(errorText(error)); await load(posts.page); }
+  };
+
+  const columns: ColumnsType<ChannelPost> = [
+    { title: '内容', minWidth: 240, render: (_, post) => <Space direction="vertical" size={0}><Typography.Text ellipsis style={{ maxWidth: 320 }}>{post.text || `[${post.media_type || '媒体'}]`}</Typography.Text><Typography.Text type="secondary">{post.media_type || '文字'} · {post.source === 'admin' ? '后台' : 'Telegram'}</Typography.Text></Space> },
+    { title: '状态', width: 120, render: (_, post) => <Space direction="vertical" size={0}><Tag color={post.status === 'published' ? 'success' : post.status === 'uncertain' || post.status === 'failed' ? 'error' : 'default'}>{statusLabels[post.status]}</Tag>{post.last_error && <Typography.Text type="danger">{post.last_error}</Typography.Text>}{post.pin_error && <Typography.Text type="warning">置顶失败</Typography.Text>}</Space> },
+    { title: '时间', width: 170, render: (_, post) => dayjs(post.publish_at || post.published_at || post.created_at).format('YYYY-MM-DD HH:mm') },
+    { title: '消息 ID', width: 130, render: (_, post) => post.message_id || '-' },
+    { title: '操作', width: 150, render: (_, post) => <Space>
+      {['scheduled', 'published'].includes(post.status) && post.media_type !== 'other' && <Button type="link" onClick={() => openEdit(post)}>编辑</Button>}
+      {['scheduled', 'published'].includes(post.status) && <Popconfirm title={post.status === 'scheduled' ? '取消定时发布?' : '从 Telegram 删除帖子?'} onConfirm={async () => { try { await apiRequest(`/channels/${chatId}/posts/${post.post_id}`, jsonOptions('DELETE', { confirm: true })); message.success(post.status === 'scheduled' ? '已取消' : '已删除'); await load(posts.page); } catch (error) { message.error(errorText(error)); } }}><Button danger type="link">{post.status === 'scheduled' ? '取消' : '删除'}</Button></Popconfirm>}
+    </Space> },
+  ];
+
+  return <>
+    <Surface title="发布帖子">
+      <Form form={form} layout="vertical" onFinish={(values) => Modal.confirm({ title: values.publish_at ? '确认安排定时发布?' : '确认立即发布到频道?', okText: '确认', onOk: () => create(values) })}>
+        <Form.Item name="text" label="文字或媒体说明"><Input.TextArea rows={6} maxLength={media ? 1024 : 4096} showCount /></Form.Item>
+        <Form.Item label="媒体">{mediaButton(setMedia)} {media && <Tag closable onClose={() => setMedia(null)}>{media.filename}</Tag>}</Form.Item>
+        <Form.Item name="publish_at" label="定时发布(留空为立即发布)"><DatePicker showTime style={{ width: '100%' }} disabledDate={(date) => date.isBefore(dayjs(), 'day')} /></Form.Item>
+        <Form.Item name="pin" valuePropName="checked"><Checkbox>发布后置顶</Checkbox></Form.Item>
+        <Button type="primary" htmlType="submit">发布或安排</Button>
+      </Form>
+    </Surface>
+    <div style={{ height: 16 }} />
+    <Surface title="频道新帖记录" actions={<Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void load(posts.page)}>刷新</Button>}>
+      <Table rowKey="post_id" columns={columns} dataSource={posts.items} loading={loading} scroll={{ x: 850 }} pagination={{ current: posts.page, pageSize: posts.page_size, total: posts.total, showSizeChanger: false }} onChange={(next) => void load(next.current || 1)} />
+    </Surface>
+    <Modal title={editing?.status === 'scheduled' ? '编辑定时帖子' : '编辑已发布帖子'} open={Boolean(editing)} okText="确认修改" onOk={() => editForm.submit()} onCancel={() => setEditing(null)} destroyOnHidden>
+      <Form form={editForm} layout="vertical" onFinish={(values) => Modal.confirm({ title: '确认修改帖子?', okText: '确认修改', onOk: () => saveEdit(values) })}>
+        <Form.Item name="text" label="文字或媒体说明"><Input.TextArea rows={6} maxLength={editMedia || editing?.media_type ? 1024 : 4096} showCount /></Form.Item>
+        {(editing?.status === 'scheduled' || editing?.media_type) && <Form.Item label="替换或添加媒体">{mediaButton(setEditMedia)} {editMedia && <Tag closable onClose={() => setEditMedia(null)}>{editMedia.filename}</Tag>}</Form.Item>}
+        {editing?.status === 'scheduled' && <><Form.Item name="publish_at" label="发布时间" rules={[{ required: true }]}><DatePicker showTime style={{ width: '100%' }} /></Form.Item><Form.Item name="pin" valuePropName="checked"><Checkbox>发布后置顶</Checkbox></Form.Item></>}
+      </Form>
+    </Modal>
+  </>;
+}

+ 85 - 0
admin-web/src/pages/Channels.tsx

@@ -0,0 +1,85 @@
+import { Button, Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import dayjs from 'dayjs';
+import { history } from '@umijs/max';
+import { Plus, RefreshCw, Search, Settings2 } from 'lucide-react';
+import { useEffect, useState } from 'react';
+
+import { PageHeader, Surface } from '@/components/Page';
+import { useBotAccess } from '@/contexts/BotAccess';
+import { apiRequest, jsonOptions } from '@/services/api';
+import type { ChatSummary, Paged } from '@/types';
+
+export default function ChannelsPage() {
+  const { hasPermission } = useBotAccess();
+  const [data, setData] = useState<Paged<ChatSummary>>({ items: [], total: 0, page: 1, page_size: 20 });
+  const [query, setQuery] = useState('');
+  const [loading, setLoading] = useState(false);
+  const [addOpen, setAddOpen] = useState(false);
+  const [chatId, setChatId] = useState('');
+  const [adding, setAdding] = useState(false);
+
+  const load = async (page = 1, search = query) => {
+    setLoading(true);
+    try {
+      const params = new URLSearchParams({ page: String(page), page_size: '20', query: search });
+      setData(await apiRequest<Paged<ChatSummary>>(`/channels?${params}`));
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '频道加载失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    void load(1, '');
+    const refresh = () => void load(1, '');
+    window.addEventListener('bot-selection-changed', refresh);
+    return () => window.removeEventListener('bot-selection-changed', refresh);
+  }, []);
+
+  const add = async () => {
+    if (!/^-?\d+$/.test(chatId.trim())) {
+      message.error('请输入频道数字 ID');
+      return;
+    }
+    setAdding(true);
+    try {
+      await apiRequest('/channels', jsonOptions('POST', { chat_id: chatId.trim(), confirm: true }));
+      message.success('频道已接入');
+      setAddOpen(false);
+      setChatId('');
+      await load(1);
+    } catch (error) {
+      message.error(error instanceof Error ? error.message : '频道接入失败');
+    } finally {
+      setAdding(false);
+    }
+  };
+
+  const columns: ColumnsType<ChatSummary> = [
+    {
+      title: '频道', dataIndex: 'title', minWidth: 220,
+      render: (value, record) => <Space direction="vertical" size={0}><Typography.Text strong>{value}</Typography.Text><Typography.Text type="secondary">{record.chat_id}</Typography.Text></Space>,
+    },
+    { title: '用户名', dataIndex: 'username', width: 170, render: (value) => value ? `@${value}` : '-' },
+    { title: '订阅人数', dataIndex: 'member_count', width: 120, render: (value) => value ?? '-' },
+    { title: 'Bot 状态', dataIndex: 'bot_status', width: 130, render: (value, record) => <Tag color={record.accessible && ['owner', 'administrator'].includes(value) ? 'success' : 'error'}>{value || (record.accessible ? '待同步' : '不可用')}</Tag> },
+    { title: '最近同步', dataIndex: 'last_seen_at', width: 160, render: (value) => value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-' },
+    { title: '操作', width: 110, render: (_, record) => <Button type="link" icon={<Settings2 size={16} />} disabled={!record.accessible} onClick={() => history.push(`/channels/${record.chat_id}`)}>管理</Button> },
+  ];
+
+  return <>
+    <PageHeader title="频道管理" actions={<Space>
+      {hasPermission('channel.profile') && <Button type="primary" icon={<Plus size={16} />} onClick={() => setAddOpen(true)}>接入频道</Button>}
+      <Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void load(data.page)}>刷新</Button>
+    </Space>} />
+    <Surface actions={<Input allowClear value={query} onChange={(event) => setQuery(event.target.value)} onPressEnter={() => void load(1)} prefix={<Search size={16} />} placeholder="频道名称、@username 或 ID" style={{ width: 'min(360px, 100%)' }} />}>
+      <Table rowKey="chat_id" columns={columns} dataSource={data.items} loading={loading} scroll={{ x: 800 }} pagination={{ current: data.page, pageSize: data.page_size, total: data.total, showSizeChanger: false, showTotal: (total) => `共 ${total} 个频道` }} onChange={(next) => void load(next.current || 1)} />
+    </Surface>
+    <Modal title="接入频道" open={addOpen} okText="校验并接入" okButtonProps={{ loading: adding }} onOk={() => void add()} onCancel={() => setAddOpen(false)}>
+      <Typography.Paragraph type="secondary">请先把当前 Bot 设为频道管理员,再输入频道数字 ID。</Typography.Paragraph>
+      <Input value={chatId} onChange={(event) => setChatId(event.target.value)} placeholder="例如 -1001234567890" onPressEnter={() => void add()} />
+    </Modal>
+  </>;
+}

+ 25 - 0
admin-web/src/types.ts

@@ -265,6 +265,31 @@ export interface ChatOverview extends ChatSummary {
   automation: AutomationSettings;
 }
 
+export interface ChannelOverview extends ChatSummary {
+  description?: string | null;
+  photo_data_url?: string | null;
+}
+
+export interface ChannelPost {
+  post_id: string;
+  chat_id: string;
+  message_id?: string | null;
+  source: 'admin' | 'telegram';
+  status: 'scheduled' | 'sending' | 'published' | 'uncertain' | 'failed' | 'canceled' | 'deleted';
+  text: string;
+  media_type?: string | null;
+  file_id?: string | null;
+  media_filename?: string | null;
+  media_size?: number | null;
+  media_mime_type?: string | null;
+  pin: boolean;
+  publish_at?: string | null;
+  published_at?: string | null;
+  created_at: string;
+  last_error?: string;
+  pin_error?: string;
+}
+
 export interface TelegramUser {
   id: string;
   username?: string | null;

+ 46 - 0
admin-web/tests/unit/Channels.test.tsx

@@ -0,0 +1,46 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, expect, it, vi } from 'vitest';
+
+import ChannelsPage from '@/pages/Channels';
+import ChannelDetailPage from '@/pages/ChannelDetail';
+import { apiRequest } from '@/services/api';
+
+vi.mock('@/services/api', () => ({
+  apiRequest: vi.fn(),
+  jsonOptions: vi.fn((_method, body) => ({ body })),
+}));
+
+beforeEach(() => vi.clearAllMocks());
+
+it('频道列表从独立接口加载并支持手动接入', async () => {
+  vi.mocked(apiRequest).mockImplementation(async (path) => {
+    if (path === '/channels') return { chat_id: '-100', type: 'channel' };
+    return {
+      items: [{ chat_id: '-100', title: '运营频道', type: 'channel', accessible: true, bot_status: 'administrator' }],
+      total: 1, page: 1, page_size: 20,
+    };
+  });
+  render(<ChannelsPage />);
+  expect(await screen.findByText('运营频道')).toBeInTheDocument();
+  fireEvent.click(screen.getByRole('button', { name: '接入频道' }));
+  fireEvent.change(screen.getByPlaceholderText('例如 -1001234567890'), { target: { value: '-100234' } });
+  fireEvent.click(screen.getByRole('button', { name: '校验并接入' }));
+  await waitFor(() => expect(apiRequest).toHaveBeenCalledWith('/channels', { body: { chat_id: '-100234', confirm: true } }));
+});
+
+it('频道详情展示频道资料、管理员与帖子记录', async () => {
+  vi.mocked(apiRequest).mockImplementation(async (path) => {
+    if (path === '/channels/-100') return {
+      chat_id: '-100', title: '运营频道', description: '频道简介', type: 'channel',
+      accessible: true, bot_status: 'administrator', bot_privileges: ['can_post_messages'],
+    };
+    if (path.endsWith('/invites')) return { items: [] };
+    if (path.endsWith('/admins')) return { items: [] };
+    if (path.includes('/posts?')) return { items: [{ post_id: 'p1', chat_id: '-100', message_id: '8', source: 'telegram', status: 'published', text: '频道新帖', pin: false, created_at: '2026-09-28T00:00:00Z' }], total: 1, page: 1, page_size: 20 };
+    return {};
+  });
+  render(<ChannelDetailPage />);
+  expect(await screen.findByText('频道简介')).toBeInTheDocument();
+  fireEvent.click(screen.getByText('帖子与定时'));
+  expect(await screen.findByText('频道新帖')).toBeInTheDocument();
+});

+ 124 - 0
tests/test_channel_api.py

@@ -0,0 +1,124 @@
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+from aiohttp import CookieJar
+from aiohttp.test_utils import TestClient, TestServer
+from pyrogram.enums import ChatMemberStatus, ChatType
+
+
+async def test_gif_upload_uses_telegram_returned_media_type(app_modules, monkeypatch):
+    api = app_modules.load("wbb.admin.api")
+    monkeypatch.setattr(api, "MESSAGE_DUMP_CHAT", -100999)
+    monkeypatch.setattr(
+        api.telegram_app,
+        "send_animation",
+        AsyncMock(return_value=SimpleNamespace(
+            animation=None, document=SimpleNamespace(file_id="gif-as-document")
+        )),
+        raising=False,
+    )
+
+    class Part:
+        name = "file"
+        filename = "sample.gif"
+        headers = {"Content-Type": "image/gif"}
+
+        def __init__(self):
+            self.sent = False
+
+        async def read_chunk(self, _size):
+            if self.sent:
+                return b""
+            self.sent = True
+            return b"GIF89a"
+
+    class Reader:
+        async def next(self):
+            return Part()
+
+    class Request(dict):
+        async def multipart(self):
+            return Reader()
+
+    response = await api.AdminApi().upload_media(Request())
+    result = json.loads(response.text)["data"]
+    assert result["type"] == "document"
+    assert result["file_id"] == "gif-as-document"
+    assert result["mime_type"] == "image/gif"
+
+
+async def test_channel_api_onboarding_schedule_and_role_gate(app_modules):
+    chat_id = -100123
+    app_modules.app.chats[chat_id] = SimpleNamespace(
+        id=chat_id, type=ChatType.CHANNEL, title="运营频道", username="ops_channel",
+        description="简介", permissions=None, photo=None,
+    )
+    app_modules.app.members[(chat_id, 999)] = SimpleNamespace(
+        status=ChatMemberStatus.ADMINISTRATOR,
+        privileges=SimpleNamespace(
+            can_manage_chat=True, can_post_messages=True, can_pin_messages=True,
+            can_change_info=True, can_edit_messages=True, can_delete_messages=True,
+        ),
+    )
+    api = app_modules.load("wbb.admin.api")
+    application = api.build_admin_application()
+    await application["admin_api"].initialize()
+    client = TestClient(TestServer(application), cookie_jar=CookieJar(unsafe=True))
+    await client.start_server()
+    try:
+        login = await client.post(
+            "/api/admin/v1/auth/login",
+            json={"username": "admin", "password": "qwe0.123456"},
+        )
+        token = (await login.json())["data"]["csrf_token"]
+        changed = await client.put(
+            "/api/admin/v1/auth/password",
+            headers={"X-CSRF-Token": token},
+            json={"current_password": "qwe0.123456", "new_password": "changed-pass-123"},
+        )
+        token = (await changed.json())["data"]["csrf_token"]
+        headers = {"X-CSRF-Token": token}
+
+        connected = await client.post(
+            "/api/admin/v1/channels", headers=headers,
+            json={"chat_id": str(chat_id), "confirm": True},
+        )
+        assert connected.status == 201
+        assert (await connected.json())["data"]["type"] == "channel"
+        listed = await client.get("/api/admin/v1/channels")
+        assert [item["chat_id"] for item in (await listed.json())["data"]["items"]] == [str(chat_id)]
+        groups = await client.get("/api/admin/v1/chats")
+        assert (await groups.json())["data"]["total"] == 0
+
+        scheduled = await client.post(
+            f"/api/admin/v1/channels/{chat_id}/posts", headers=headers,
+            json={
+                "text": "稍后发布", "publish_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
+                "confirm": True,
+            },
+        )
+        assert scheduled.status == 201
+        post = (await scheduled.json())["data"]
+        assert post["status"] == "scheduled" and post["message_id"] is None
+        updated = await client.patch(
+            f"/api/admin/v1/channels/{chat_id}/posts/{post['post_id']}", headers=headers,
+            json={"text": "更新的帖子", "confirm": True},
+        )
+        assert updated.status == 200
+        assert (await updated.json())["data"]["text"] == "更新的帖子"
+        canceled = await client.delete(
+            f"/api/admin/v1/channels/{chat_id}/posts/{post['post_id']}", headers=headers,
+            json={"confirm": True},
+        )
+        assert (await canceled.json())["data"]["status"] == "canceled"
+
+        app_modules.wbb.BOT_PERMISSIONS = {"channel.profile"}
+        denied = await client.get(f"/api/admin/v1/channels/{chat_id}/posts")
+        assert denied.status == 403
+        assert (await denied.json())["error"]["code"] == "bot_role_permission_denied"
+    finally:
+        await client.close()

+ 326 - 0
tests/test_channel_management.py

@@ -0,0 +1,326 @@
+from __future__ import annotations
+
+import asyncio
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from pyrogram.enums import ChatMemberStatus, ChatType
+from pyrogram.errors import MessageEmpty
+
+
+def prepare_channel(app_modules, *, chat_id: int = -100123) -> None:
+    app = app_modules.wbb.app
+    app.chats[chat_id] = SimpleNamespace(
+        id=chat_id,
+        type=ChatType.CHANNEL,
+        title="测试频道",
+        username="test_channel",
+        description="简介",
+        permissions=None,
+        photo=None,
+    )
+    app.members[(chat_id, 999)] = SimpleNamespace(
+        status=ChatMemberStatus.ADMINISTRATOR,
+        privileges=SimpleNamespace(
+            can_manage_chat=True,
+            can_post_messages=True,
+            can_edit_messages=True,
+            can_delete_messages=True,
+            can_pin_messages=True,
+            can_change_info=True,
+            can_promote_members=True,
+            can_invite_users=True,
+        ),
+    )
+
+
+async def test_channel_connect_rejects_group_and_non_admin(app_modules):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    result = await service.ensure_channel(-100123)
+    assert result["type"] == "channel"
+
+    app_modules.wbb.app.members[(-100123, 999)].status = ChatMemberStatus.MEMBER
+    with pytest.raises(service.ChatManagementError) as caught:
+        await service.ensure_channel(-100123)
+    assert caught.value.code == "bot_not_admin"
+
+    app_modules.wbb.app.chats[-100123].type = ChatType.SUPERGROUP
+    with pytest.raises(service.ChatManagementError) as caught:
+        await service.ensure_channel(-100123)
+    assert caught.value.code == "not_channel"
+
+
+async def test_manual_channel_connect_resolves_uncached_public_id(app_modules, monkeypatch):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    app_modules.wbb.BOT_TOKEN = "test-token"
+    original_get_chat = app_modules.app.get_chat
+    warmed = False
+
+    async def get_chat(peer):
+        nonlocal warmed
+        if peer == -100123 and not warmed:
+            raise ValueError("Peer id invalid: -100123")
+        if peer == "@test_channel":
+            warmed = True
+            return await original_get_chat(-100123)
+        return await original_get_chat(peer)
+
+    class Response:
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *_args):
+            return None
+
+        async def json(self):
+            return {"ok": True, "result": {"id": -100123, "type": "channel", "username": "test_channel"}}
+
+    class Session:
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *_args):
+            return None
+
+        def post(self, *_args, **_kwargs):
+            return Response()
+
+    monkeypatch.setattr(app_modules.app, "get_chat", get_chat)
+    monkeypatch.setattr(service, "ClientSession", lambda **_kwargs: Session())
+    result = await service.ensure_channel(-100123)
+    assert result["chat_id"] == -100123 and warmed
+
+
+async def test_channel_list_and_group_list_are_isolated(app_modules):
+    service = app_modules.load("wbb.services.channel_management")
+    chat_service = app_modules.load("wbb.services.chat_management")
+    prepare_channel(app_modules)
+    await service.ensure_channel(-100123)
+    await app_modules.wbb.db.managed_chats.insert_one({
+        "bot_id": "primary", "chat_id": -100456, "type": "supergroup",
+        "title": "群", "accessible": True,
+    })
+    channels, channel_total = await service.list_channels(query="", page=1, page_size=20)
+    groups, group_total = await chat_service.list_accessible_chats(page=1, page_size=20)
+    assert channel_total == 1 and channels[0]["chat_id"] == -100123
+    assert group_total == 1 and groups[0]["chat_id"] == -100456
+
+
+async def test_generic_channel_observation_keeps_unavailable_status(app_modules):
+    dbadmin = app_modules.load("wbb.utils.dbadmin")
+    await dbadmin.upsert_managed_chat(
+        chat_id=-100123, title="频道", username=None, chat_type="channel",
+        accessible=False, bot_status="administrator",
+    )
+    await dbadmin.upsert_managed_chat(
+        chat_id=-100123, title="频道新名", username=None, chat_type="channel",
+        accessible=None,
+    )
+    record = await app_modules.wbb.db.managed_chats.find_one({"chat_id": -100123})
+    assert record["accessible"] is False
+    assert record["title"] == "频道新名"
+
+
+async def test_channel_observation_deduplicates_updates_and_scopes_bot(app_modules):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    message = SimpleNamespace(
+        chat=app_modules.wbb.app.chats[-100123], id=8,
+        text="原文", caption=None, media=None, date=datetime.now(UTC),
+    )
+    await service.observe_channel_post(message)
+    message.text = "改文"
+    await service.observe_channel_post(message)
+    assert await service.postsdb.count_documents({}) == 1
+    post = await service.postsdb.find_one({"bot_id": "primary"})
+    assert post["text"] == "改文" and post["source"] == "telegram"
+    await service.observe_channel_deletion(-100123, 8)
+    assert (await service.postsdb.find_one({"post_id": post["post_id"]}))["status"] == "deleted"
+
+    app_modules.wbb.BOT_PROFILE_ID = "second"
+    service.BOT_PROFILE_ID = "second"
+    dbmodule = app_modules.load("wbb.utils.dbchannel")
+    dbmodule.BOT_PROFILE_ID = "second"
+    await service.observe_channel_post(message)
+    assert await service.postsdb.count_documents({}) == 2
+
+
+async def test_scheduled_post_publishes_once_and_can_be_canceled(app_modules):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    scheduled = await service.create_channel_post(-100123, {
+        "text": "计划帖", "publish_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
+    })
+    assert scheduled["status"] == "scheduled"
+    changed = await service.edit_channel_post(-100123, scheduled["post_id"], {"text": "更新计划"})
+    assert changed["text"] == "更新计划"
+    await service.postsdb.update_one(
+        {"post_id": scheduled["post_id"]},
+        {"$set": {"publish_at": datetime.now(UTC) - timedelta(seconds=1)}},
+    )
+    await service.sweep_channel_posts()
+    await service.sweep_channel_posts()
+    assert app_modules.wbb.app.sent_messages == [(-100123, "更新计划")]
+    assert (await service.get_post(-100123, scheduled["post_id"]))["status"] == "published"
+
+    canceled = await service.create_channel_post(-100123, {
+        "text": "不发", "publish_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
+    })
+    assert (await service.delete_channel_post(-100123, canceled["post_id"]))["status"] == "canceled"
+
+
+async def test_scheduled_post_can_be_edited_near_publish_time(app_modules):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    scheduled = await service.create_channel_post(-100123, {
+        "text": "临近发布", "publish_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
+    })
+    await service.postsdb.update_one(
+        {"post_id": scheduled["post_id"]},
+        {"$set": {"publish_at": datetime.now(UTC) + timedelta(seconds=2)}},
+    )
+    current = await service.get_post(-100123, scheduled["post_id"])
+    updated = await service.edit_channel_post(-100123, scheduled["post_id"], {
+        "text": "已修改", "publish_at": current["publish_at"].replace(tzinfo=UTC).isoformat(),
+    })
+    assert updated["text"] == "已修改"
+    assert updated["status"] == "scheduled"
+
+
+async def test_uncertain_send_is_not_retried(app_modules, monkeypatch):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    send = AsyncMock(side_effect=TimeoutError("timeout after send"))
+    monkeypatch.setattr(service.app, "send_message", send)
+    with pytest.raises(service.ChatManagementError) as caught:
+        await service.create_channel_post(-100123, {"text": "结果不明"})
+    assert caught.value.code == "post_delivery_uncertain"
+    await service.sweep_channel_posts()
+    assert send.await_count == 1
+    post = await service.postsdb.find_one({"text": "结果不明"})
+    assert post["status"] == "uncertain"
+
+
+async def test_telegram_rejection_is_failed_not_uncertain(app_modules, monkeypatch):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    monkeypatch.setattr(
+        service.app, "send_message", AsyncMock(side_effect=MessageEmpty()),
+    )
+    with pytest.raises(service.ChatManagementError) as caught:
+        await service.create_channel_post(-100123, {"text": "Telegram 拒绝"})
+    assert caught.value.code == "post_send_failed"
+    post = await service.postsdb.find_one({"text": "Telegram 拒绝"})
+    assert post["status"] == "failed"
+
+
+async def test_published_post_edit_and_delete_require_telegram_permission(app_modules, monkeypatch):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    post = await service.create_channel_post(-100123, {"text": "原文"})
+    edited = await service.edit_channel_post(-100123, post["post_id"], {"text": "新文"})
+    assert edited["text"] == "新文"
+    assert app_modules.wbb.app.edited_messages == [(-100123, 1, "新文")]
+    monkeypatch.setattr(service.app, "delete_messages", AsyncMock(return_value=1))
+    deleted = await service.delete_channel_post(-100123, post["post_id"])
+    assert deleted["status"] == "deleted"
+
+
+async def test_media_post_can_replace_media_and_channel_admin_rights_are_checked(app_modules, monkeypatch):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    post = await service.create_channel_post(-100123, {
+        "text": "第一张", "media_type": "photo", "file_id": "photo-file-1",
+        "media_filename": "first.jpg", "media_size": 1234, "media_mime_type": "image/jpeg",
+    })
+    assert app_modules.app.sent_photos == [(-100123, "photo-file-1", "第一张")]
+    assert post["media_filename"] == "first.jpg" and post["media_size"] == 1234
+    edit_media = AsyncMock()
+    monkeypatch.setattr(service.app, "edit_message_media", edit_media, raising=False)
+    changed = await service.edit_channel_post(-100123, post["post_id"], {
+        "text": "第二张", "media_type": "photo", "file_id": "photo-file-2",
+    })
+    assert changed["file_id"] == "photo-file-2"
+    assert edit_media.await_count == 1
+
+    app_modules.app.members[(-100123, 42)] = SimpleNamespace(
+        status=ChatMemberStatus.MEMBER,
+        user=SimpleNamespace(id=42, username=None, first_name="用户", last_name=None, is_bot=False, is_deleted=False),
+        privileges=None, custom_title=None, until_date=None,
+    )
+    promote = AsyncMock()
+    monkeypatch.setattr(service.app, "promote_chat_member", promote, raising=False)
+    await service.set_channel_admin(-100123, 42, ["can_post_messages"])
+    assert promote.await_count == 1
+    with pytest.raises(service.ChatManagementError) as caught:
+        await service.set_channel_admin(-100123, 42, ["can_restrict_members"])
+    assert caught.value.code == "invalid_privileges"
+
+
+@pytest.mark.parametrize("media_type, method, argument", [
+    ("animation", "send_animation", "animation"),
+    ("video", "send_video", "video"),
+    ("document", "send_document", "document"),
+])
+async def test_media_post_dispatches_supported_types(app_modules, monkeypatch, media_type, method, argument):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    send = AsyncMock(return_value=SimpleNamespace(id=91))
+    monkeypatch.setattr(service.app, method, send, raising=False)
+    post = await service.create_channel_post(-100123, {
+        "text": "媒体帖", "media_type": media_type, "file_id": "telegram-file-id",
+    })
+    assert post["status"] == "published" and post["message_id"] == 91
+    assert send.await_args.args == (-100123,)
+    assert send.await_args.kwargs[argument] == "telegram-file-id"
+
+
+async def test_revoked_bot_post_role_prevents_scheduled_send(app_modules):
+    service = app_modules.load("wbb.services.channel_management")
+    prepare_channel(app_modules)
+    scheduled = await service.create_channel_post(-100123, {
+        "text": "原定发布", "publish_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
+    })
+    await service.postsdb.update_one(
+        {"post_id": scheduled["post_id"]},
+        {"$set": {"publish_at": datetime.now(UTC) - timedelta(seconds=1)}},
+    )
+    service.BOT_PERMISSIONS = {"channel.profile"}
+    await service.sweep_channel_posts()
+    assert app_modules.app.sent_messages == []
+    assert (await service.get_post(-100123, scheduled["post_id"]))["status"] == "failed"
+
+
+async def test_concurrent_claim_and_stale_restart_do_not_resend(app_modules):
+    service = app_modules.load("wbb.services.channel_management")
+    dbmodule = app_modules.load("wbb.utils.dbchannel")
+    prepare_channel(app_modules)
+    scheduled = await service.create_channel_post(-100123, {
+        "text": "重启前任务", "publish_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
+    })
+    await service.postsdb.update_one(
+        {"post_id": scheduled["post_id"]},
+        {"$set": {"publish_at": datetime.now(UTC) - timedelta(seconds=1)}},
+    )
+    first, second = await asyncio.gather(dbmodule.claim_due_posts(), dbmodule.claim_due_posts())
+    assert len(first) + len(second) == 1
+    await service.postsdb.update_one(
+        {"post_id": scheduled["post_id"]},
+        {"$set": {"updated_at": datetime.now(UTC) - timedelta(minutes=6)}},
+    )
+    await service.sweep_channel_posts()
+    assert app_modules.app.sent_messages == []
+    assert (await service.get_post(-100123, scheduled["post_id"]))["status"] == "uncertain"
+
+
+def test_channel_route_permissions(app_modules):
+    permissions = app_modules.load("wbb.services.bot_permissions")
+    assert permissions.api_permission("GET", "/api/admin/v1/channels") == "channel.any"
+    assert permissions.api_permission("GET", "/api/admin/v1/channels/-100") == "channel.any"
+    assert permissions.api_permission("POST", "/api/admin/v1/channels") == "channel.profile"
+    assert permissions.api_permission("PUT", "/api/admin/v1/channels/-100/admins/42") == "channel.admins"
+    assert permissions.api_permission("PATCH", "/api/admin/v1/channels/-100/posts/p1") == "channel.posts"

+ 1 - 0
wbb/__init__.py

@@ -62,6 +62,7 @@ MOD_LOAD = [
     "blacklist",
     "blacklist_chat",
     "business_assistant",
+    "channels",
     "chat_watcher",
     "chatbot",
     "filters",

+ 191 - 4
wbb/admin/api.py

@@ -41,12 +41,25 @@ from wbb.admin.telegram_webapp import (
     TelegramWebAppAuthError,
     verify_telegram_webapp_init_data,
 )
-from wbb.services.bot_permissions import api_permission, has_permission
+from wbb.services.bot_permissions import api_permission, has_any_permission, has_permission
 from wbb.services.business_assistant import (
     AssistantProviderError,
     FeishuWebhookError,
     runtime_overview,
 )
+from wbb.services.channel_management import (
+    create_channel_post,
+    delete_channel_post,
+    edit_channel_post,
+    ensure_channel,
+    list_channel_admins,
+    list_channels,
+    public_post,
+    remove_channel_admin,
+    set_channel_admin,
+    update_channel_photo,
+    update_channel_profile,
+)
 from wbb.services.chat_management import (
     ChatManagementError,
     apply_automation_settings,
@@ -158,6 +171,7 @@ from wbb.utils.dbassistant import (
     update_knowledge_source,
     usage_metrics,
 )
+from wbb.utils.dbchannel import list_posts
 from wbb.utils.dbdirectory import (
     APPLICATION_APPROVED,
     DirectoryDataError,
@@ -235,6 +249,7 @@ PUBLIC_API_PATHS = {f"{API_PREFIX}/auth/login", f"{API_PREFIX}/health"}
 BOT_SCOPED_PREFIXES = (
     f"{API_PREFIX}/business-assistant",
     f"{API_PREFIX}/chats",
+    f"{API_PREFIX}/channels",
     f"{API_PREFIX}/giveaways",
     f"{API_PREFIX}/points",
     f"{API_PREFIX}/media",
@@ -623,7 +638,15 @@ async def bot_role_middleware(request: web.Request, handler):
         permissions = profile.get("permissions", [])
     else:
         permissions = getattr(wbb, "BOT_PERMISSIONS", {"*"})
-    if not has_permission(required, permissions):
+    allowed = (
+        has_any_permission(
+            {"channel.profile", "channel.admins", "channel.invites", "channel.posts"},
+            permissions,
+        )
+        if required == "channel.any"
+        else has_permission(required, permissions)
+    )
+    if not allowed:
         raise ApiProblem(
             "bot_role_permission_denied",
             "所选机器人的职责角色不允许执行该操作。",
@@ -760,6 +783,22 @@ class AdminApi:
         router.add_get(f"{API_PREFIX}/audit-logs", self.audit_logs)
         router.add_post(f"{API_PREFIX}/media", self.upload_media)
 
+        router.add_get(f"{API_PREFIX}/channels", self.channels)
+        router.add_post(f"{API_PREFIX}/channels", self.channel_add)
+        router.add_get(f"{API_PREFIX}/channels/{{chat_id}}", self.channel)
+        router.add_patch(f"{API_PREFIX}/channels/{{chat_id}}/profile", self.channel_profile)
+        router.add_post(f"{API_PREFIX}/channels/{{chat_id}}/photo", self.channel_photo)
+        router.add_get(f"{API_PREFIX}/channels/{{chat_id}}/admins", self.channel_admins)
+        router.add_put(f"{API_PREFIX}/channels/{{chat_id}}/admins/{{user_id}}", self.channel_admin_update)
+        router.add_delete(f"{API_PREFIX}/channels/{{chat_id}}/admins/{{user_id}}", self.channel_admin_remove)
+        router.add_get(f"{API_PREFIX}/channels/{{chat_id}}/invites", self.channel_invites)
+        router.add_post(f"{API_PREFIX}/channels/{{chat_id}}/invites", self.channel_invite_create)
+        router.add_delete(f"{API_PREFIX}/channels/{{chat_id}}/invites", self.channel_invite_revoke)
+        router.add_get(f"{API_PREFIX}/channels/{{chat_id}}/posts", self.channel_posts)
+        router.add_post(f"{API_PREFIX}/channels/{{chat_id}}/posts", self.channel_post_create)
+        router.add_patch(f"{API_PREFIX}/channels/{{chat_id}}/posts/{{post_id}}", self.channel_post_edit)
+        router.add_delete(f"{API_PREFIX}/channels/{{chat_id}}/posts/{{post_id}}", self.channel_post_delete)
+
         router.add_get(
             f"{API_PREFIX}/business-assistant/status",
             self.business_assistant_status,
@@ -2136,9 +2175,12 @@ class AdminApi:
                         raise ApiProblem("file_too_large", "上传文件超过 20 MB 限制。", status=413)
                     handle.write(chunk)
             if content_type == "image/gif":
-                media_type = "animation"
                 sent = await telegram_app.send_animation(MESSAGE_DUMP_CHAT, str(temporary_path))
-                file_id = sent.animation.file_id
+                media = sent.animation or sent.document
+                if not media:
+                    raise ApiProblem("media_upload_failed", "Telegram 未能保存该动图。", status=502)
+                media_type = "animation" if sent.animation else "document"
+                file_id = media.file_id
             elif content_type.startswith("image/"):
                 media_type = "photo"
                 sent = await telegram_app.send_photo(MESSAGE_DUMP_CHAT, str(temporary_path))
@@ -2163,10 +2205,155 @@ class AdminApi:
                 "type": media_type,
                 "filename": filename,
                 "size": size,
+                "mime_type": content_type,
             },
             status=201,
         )
 
+    async def channels(self, request: web.Request) -> web.Response:
+        page, page_size = page_params(request)
+        items, total = await list_channels(
+            query=request.query.get("query", ""), page=page, page_size=page_size
+        )
+        return success({"items": items, "total": total, "page": page, "page_size": page_size})
+
+    async def channel_add(self, request: web.Request) -> web.Response:
+        body = await json_body(request)
+        require_confirmation(body)
+        chat_id = parse_int(body.get("chat_id"), "chat_id")
+        set_audit(request, "channel.connect", chat_id=chat_id)
+        return success(await ensure_channel(chat_id), status=201)
+
+    async def channel(self, request: web.Request) -> web.Response:
+        return success(await ensure_channel(chat_id_param(request), include_photo=True))
+
+    async def channel_profile(self, request: web.Request) -> web.Response:
+        chat_id = chat_id_param(request)
+        body = await json_body(request)
+        require_confirmation(body)
+        set_audit(request, "channel.profile.update", chat_id=chat_id)
+        return success(await update_channel_profile(
+            chat_id, title=body.get("title"), description=body.get("description")
+        ))
+
+    async def channel_photo(self, request: web.Request) -> web.Response:
+        chat_id = chat_id_param(request)
+        reader = await request.multipart()
+        part = await reader.next()
+        if not part or part.name != "photo":
+            raise ApiProblem("photo_required", "请选择 JPG 或 PNG 头像。")
+        filename = Path(part.filename or "photo").name
+        suffix = Path(filename).suffix.lower()
+        if suffix not in {".jpg", ".jpeg", ".png"}:
+            raise ApiProblem("invalid_photo", "头像只能是 JPG 或 PNG。")
+        temporary_path: Path | None = None
+        try:
+            with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as handle:
+                temporary_path = Path(handle.name)
+                size = 0
+                while chunk := await part.read_chunk(1024 * 1024):
+                    size += len(chunk)
+                    if size > 5_000_000:
+                        raise ApiProblem("photo_too_large", "头像不能超过 5 MB。", status=413)
+                    handle.write(chunk)
+            from PIL import Image
+
+            try:
+                with Image.open(temporary_path) as photo:
+                    if photo.format not in {"JPEG", "PNG"}:
+                        raise ValueError("unsupported image format")
+                    photo.verify()
+            except Exception as exc:
+                raise ApiProblem("invalid_photo", "头像文件无效。") from exc
+            set_audit(request, "channel.photo.update", chat_id=chat_id, summary=filename)
+            return success(await update_channel_photo(chat_id, temporary_path))
+        finally:
+            if temporary_path:
+                temporary_path.unlink(missing_ok=True)
+
+    async def channel_admins(self, request: web.Request) -> web.Response:
+        return success({"items": await list_channel_admins(chat_id_param(request))})
+
+    async def channel_admin_update(self, request: web.Request) -> web.Response:
+        chat_id = chat_id_param(request)
+        user_id = parse_int(request.match_info["user_id"], "user_id", minimum=1)
+        body = await json_body(request)
+        require_confirmation(body)
+        privileges = body.get("privileges")
+        if not isinstance(privileges, list) or not all(isinstance(item, str) for item in privileges):
+            raise ApiProblem("invalid_privileges", "管理员权限须为字符串列表。")
+        set_audit(request, "channel.admin.update", chat_id=chat_id, target_id=user_id)
+        return success(await set_channel_admin(chat_id, user_id, privileges))
+
+    async def channel_admin_remove(self, request: web.Request) -> web.Response:
+        chat_id = chat_id_param(request)
+        user_id = parse_int(request.match_info["user_id"], "user_id", minimum=1)
+        require_confirmation(await json_body(request))
+        set_audit(request, "channel.admin.remove", chat_id=chat_id, target_id=user_id)
+        return success(await remove_channel_admin(chat_id, user_id))
+
+    async def channel_invites(self, request: web.Request) -> web.Response:
+        chat_id = chat_id_param(request)
+        await ensure_channel(chat_id)
+        return success({"items": await get_invite_links(chat_id)})
+
+    async def channel_invite_create(self, request: web.Request) -> web.Response:
+        chat_id = chat_id_param(request)
+        body = await json_body(request)
+        require_confirmation(body)
+        await ensure_channel(chat_id)
+        expires_at = parse_datetime(body["expires_at"], "expires_at") if body.get("expires_at") else None
+        member_limit = parse_int(body["member_limit"], "member_limit", minimum=1) if body.get("member_limit") else None
+        set_audit(request, "channel.invite.create", chat_id=chat_id)
+        try:
+            result = await create_invite_link(
+                chat_id, name=str(body.get("name") or "Admin panel"),
+                expires_at=expires_at, member_limit=member_limit,
+            )
+        except Exception as exc:
+            raise ChatManagementError("channel_invite_failed", "Telegram 未能创建频道邀请链接。", status=502) from exc
+        return success(result, status=201)
+
+    async def channel_invite_revoke(self, request: web.Request) -> web.Response:
+        chat_id = chat_id_param(request)
+        body = await json_body(request)
+        require_confirmation(body)
+        await ensure_channel(chat_id)
+        link = str(body.get("invite_link") or "")
+        set_audit(request, "channel.invite.revoke", chat_id=chat_id)
+        try:
+            await revoke_invite_link(chat_id, link)
+        except Exception as exc:
+            raise ChatManagementError("channel_invite_revoke_failed", "Telegram 未能撤销频道邀请链接。", status=502) from exc
+        return success({"revoked": True})
+
+    async def channel_posts(self, request: web.Request) -> web.Response:
+        chat_id = chat_id_param(request)
+        await ensure_channel(chat_id)
+        page, page_size = page_params(request)
+        items, total = await list_posts(chat_id, page=page, page_size=page_size)
+        return success({"items": [public_post(item) for item in items], "total": total, "page": page, "page_size": page_size})
+
+    async def channel_post_create(self, request: web.Request) -> web.Response:
+        chat_id = chat_id_param(request)
+        body = await json_body(request)
+        require_confirmation(body)
+        set_audit(request, "channel.post.create", chat_id=chat_id, summary=str(body.get("text") or "")[:200])
+        return success(await create_channel_post(chat_id, body), status=201)
+
+    async def channel_post_edit(self, request: web.Request) -> web.Response:
+        chat_id = chat_id_param(request)
+        body = await json_body(request)
+        require_confirmation(body)
+        set_audit(request, "channel.post.edit", chat_id=chat_id, target_id=request.match_info["post_id"])
+        return success(await edit_channel_post(chat_id, request.match_info["post_id"], body))
+
+    async def channel_post_delete(self, request: web.Request) -> web.Response:
+        chat_id = chat_id_param(request)
+        require_confirmation(await json_body(request))
+        set_audit(request, "channel.post.delete", chat_id=chat_id, target_id=request.match_info["post_id"])
+        return success(await delete_channel_post(chat_id, request.match_info["post_id"]))
+
     async def chats(self, request: web.Request) -> web.Response:
         page, page_size = page_params(request)
         items, total = await list_accessible_chats(

+ 95 - 0
wbb/modules/channels.py

@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+import asyncio
+
+from pyrogram import filters
+from pyrogram.enums import ChatMemberStatus, ChatType
+
+from wbb import BOT_ID, BOT_PERMISSIONS, app, log
+from wbb.services.bot_permissions import has_permission
+from wbb.services.channel_management import (
+    ensure_channel,
+    observe_channel_deletion,
+    observe_channel_post,
+    sweep_channel_posts,
+)
+from wbb.utils.dbadmin import mark_managed_chat_unavailable
+
+_sweeper_started = False
+
+
+@app.on_message(filters.channel, group=-29)
+async def collect_channel_post(_, message):
+    if not has_permission("channel.posts", BOT_PERMISSIONS):
+        return
+    try:
+        await observe_channel_post(message)
+    except Exception as exc:
+        log.error(f"频道帖子采集失败:{exc}")
+
+
+@app.on_edited_message(filters.channel, group=-29)
+async def collect_edited_channel_post(_, message):
+    if not has_permission("channel.posts", BOT_PERMISSIONS):
+        return
+    try:
+        await observe_channel_post(message)
+    except Exception as exc:
+        log.error(f"频道帖子编辑采集失败:{exc}")
+
+
+@app.on_deleted_messages(filters.channel, group=-29)
+async def collect_deleted_channel_posts(_, messages):
+    if not has_permission("channel.posts", BOT_PERMISSIONS):
+        return
+    for message in messages:
+        chat_id = int(getattr(getattr(message, "chat", None), "id", 0) or 0)
+        message_id = int(getattr(message, "id", 0) or 0)
+        if chat_id and message_id:
+            await observe_channel_deletion(chat_id, message_id)
+
+
+@app.on_chat_member_updated(filters.channel, group=-29)
+async def observe_channel_bot_membership(_, update):
+    member = update.new_chat_member or update.old_chat_member
+    if (
+        getattr(update.chat, "type", None) != ChatType.CHANNEL
+        or not member
+        or getattr(member.user, "id", None) != BOT_ID
+    ):
+        return
+    if update.new_chat_member and update.new_chat_member.status in {
+        ChatMemberStatus.OWNER,
+        ChatMemberStatus.ADMINISTRATOR,
+    }:
+        try:
+            await ensure_channel(int(update.chat.id))
+        except Exception as exc:
+            log.error(f"频道自动登记失败:{exc}")
+    else:
+        await mark_managed_chat_unavailable(int(update.chat.id), "Bot is no longer a channel admin")
+
+
+async def _sweep_loop() -> None:
+    await asyncio.sleep(5)
+    while True:
+        try:
+            await sweep_channel_posts()
+        except Exception as exc:
+            log.error(f"频道定时发布失败:{exc}")
+        await asyncio.sleep(15)
+
+
+def _start_sweeper() -> None:
+    global _sweeper_started
+    if _sweeper_started:
+        return
+    try:
+        loop = asyncio.get_running_loop()
+    except RuntimeError:
+        return
+    _sweeper_started = True
+    loop.create_task(_sweep_loop(), name="channel-post-sweeper")
+
+
+_start_sweeper()

+ 11 - 1
wbb/modules/chat_watcher.py

@@ -21,6 +21,8 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 """
+from pyrogram.enums import ChatType
+
 from wbb import BOT_PERMISSIONS, app
 from wbb.services.bot_permissions import has_permission
 from wbb.services.directory import observe_group_member
@@ -50,7 +52,15 @@ async def chat_watcher_func(_, message):
         return await app.leave_chat(chat_id)
 
     await add_served_chat(chat_id)
-    if chat_id < 0:
+    if chat_id < 0 and message.chat.type == ChatType.CHANNEL:
+        await upsert_managed_chat(
+            chat_id=chat_id,
+            title=message.chat.title,
+            username=message.chat.username,
+            chat_type="channel",
+            accessible=None,
+        )
+    if chat_id < 0 and message.chat.type in {ChatType.GROUP, ChatType.SUPERGROUP}:
         if has_permission("automation.manage", BOT_PERMISSIONS) or has_permission(
             "teacher_directory.manage", BOT_PERMISSIONS
         ):

+ 22 - 0
wbb/services/bot_permissions.py

@@ -28,6 +28,10 @@ PERMISSIONS = (
     BotPermission("chat.invites", "邀请链接", "群组管理", "创建和撤销邀请链接。"),
     BotPermission("chat.rules", "群规", "群组管理", "查看和维护群规。"),
     BotPermission("chat.notes", "群笔记", "群组管理", "维护群内笔记功能。"),
+    BotPermission("channel.profile", "频道资料", "频道管理", "接入频道并维护名称、简介和头像。"),
+    BotPermission("channel.admins", "频道管理员", "频道管理", "查看和调整频道管理员。"),
+    BotPermission("channel.invites", "频道邀请链接", "频道管理", "创建和撤销频道邀请链接。"),
+    BotPermission("channel.posts", "频道帖子", "频道管理", "发布、定时、编辑和删除频道帖子。"),
     BotPermission(
         "automation.manage",
         "自动化与风控",
@@ -89,6 +93,12 @@ BUILTIN_ROLES = (
             "chat.notes",
         ),
     ),
+    BuiltinBotRole(
+        "channel_manager",
+        "频道管理员",
+        "负责频道资料、管理员、邀请链接和帖子。",
+        ("channel.profile", "channel.admins", "channel.invites", "channel.posts", "media.upload"),
+    ),
     BuiltinBotRole(
         "automation_manager",
         "自动化与风控专员",
@@ -136,6 +146,7 @@ MODULE_PERMISSIONS: dict[str, frozenset[str]] = {
     "blacklist_chat": frozenset({"automation.manage"}),
     "business_assistant": frozenset({"business_assistant.manage"}),
     "chatbot": frozenset({"automation.manage"}),
+    "channels": frozenset({"channel.profile", "channel.admins", "channel.invites", "channel.posts"}),
     "filters": frozenset({"automation.manage"}),
     "flood": frozenset({"automation.manage"}),
     "greetings": frozenset({"automation.manage"}),
@@ -229,6 +240,17 @@ def module_allowed(module: str, permissions: Iterable[str] | None) -> bool:
 
 
 def api_permission(method: str, path: str) -> str | None:
+    if path.startswith("/api/admin/v1/channels"):
+        suffix = path.removeprefix("/api/admin/v1/channels").strip("/")
+        if method == "GET" and "/" not in suffix:
+            return "channel.any"
+        if "/posts" in path:
+            return "channel.posts"
+        if "/admins" in path:
+            return "channel.admins"
+        if "/invites" in path:
+            return "channel.invites"
+        return "channel.profile"
     if path.startswith("/api/admin/v1/business-assistant"):
         return "business_assistant.manage"
     if path.startswith("/api/admin/v1/directory"):

+ 561 - 0
wbb/services/channel_management.py

@@ -0,0 +1,561 @@
+from __future__ import annotations
+
+import base64
+from datetime import UTC, datetime, timedelta
+from pathlib import Path
+from typing import Any
+from uuid import uuid4
+
+from aiohttp import ClientSession, ClientTimeout
+from pymongo.errors import DuplicateKeyError
+from pyrogram.enums import ChatMemberStatus, ChatType, ParseMode
+from pyrogram.errors import RPCError
+from pyrogram.types import (
+    ChatPrivileges,
+    InputMediaAnimation,
+    InputMediaDocument,
+    InputMediaPhoto,
+    InputMediaVideo,
+)
+
+import wbb
+from wbb import BOT_ID, BOT_PERMISSIONS, BOT_PROFILE_ID, app, log
+from wbb.services.bot_permissions import has_permission
+from wbb.services.chat_management import (
+    ChatManagementError,
+    _privilege_list,
+    ensure_permission,
+    serialize_member,
+    sync_managed_chat,
+    update_chat_profile,
+)
+from wbb.utils.dbadmin import list_managed_chats, record_audit, upsert_managed_chat
+from wbb.utils.dbchannel import (
+    claim_due_posts,
+    create_post,
+    ensure_channel_indexes,
+    get_post,
+    mark_stale_sends_uncertain,
+    now_utc,
+    postsdb,
+    telegram_key,
+    update_scheduled_post,
+)
+
+MEDIA_METHODS = {
+    "photo": ("send_photo", "photo", InputMediaPhoto),
+    "animation": ("send_animation", "animation", InputMediaAnimation),
+    "video": ("send_video", "video", InputMediaVideo),
+    "document": ("send_document", "document", InputMediaDocument),
+}
+CHANNEL_ADMIN_PRIVILEGES = {
+    "can_change_info",
+    "can_post_messages",
+    "can_edit_messages",
+    "can_delete_messages",
+    "can_invite_users",
+    "can_promote_members",
+    "can_manage_video_chats",
+}
+
+
+def telegram_error_detail(exc: Exception) -> str:
+    return f"{type(exc).__name__}: {str(exc)[:180]}" if str(exc) else type(exc).__name__
+
+
+def public_post(post: dict[str, Any]) -> dict[str, Any]:
+    return {key: value for key, value in post.items() if key not in {"_id", "telegram_key"}}
+
+
+async def warm_channel_peer(chat_id: int) -> None:
+    try:
+        resolver = getattr(app, "resolve_peer", None)
+        if resolver:
+            await resolver(chat_id)
+        else:
+            await app.get_chat(chat_id)
+        return
+    except ValueError as exc:
+        if "Peer id invalid" not in str(exc):
+            raise
+    token = str(getattr(wbb, "BOT_TOKEN", "") or "")
+    if not token:
+        raise ChatManagementError("channel_peer_unavailable", "Bot 尚未识别该频道,请等待频道新帖后重试。", status=409)
+    try:
+        async with ClientSession(timeout=ClientTimeout(total=10)) as session:
+            async with session.post(
+                f"https://api.telegram.org/bot{token}/getChat", data={"chat_id": str(chat_id)}
+            ) as response:
+                data = await response.json()
+        chat = data.get("result") if data.get("ok") else None
+        if not chat or int(chat.get("id", 0)) != chat_id or chat.get("type") != "channel":
+            raise ChatManagementError("channel_unavailable", "Telegram 未能确认该频道及 Bot 的访问权限。", status=404)
+        username = chat.get("username")
+        if not username:
+            raise ChatManagementError(
+                "channel_peer_unavailable", "Bot 尚未缓存该私有频道;请在频道发布新帖后重试。", status=409
+            )
+        await app.get_chat(f"@{username}")
+    except ChatManagementError:
+        raise
+    except Exception as exc:
+        raise ChatManagementError("channel_peer_unavailable", "Bot 暂时无法解析频道 ID,请稍后重试。", status=502) from exc
+
+
+async def ensure_channel(
+    chat_id: int, *, admin: bool = True, include_photo: bool = False
+) -> dict[str, Any]:
+    await warm_channel_peer(chat_id)
+    overview = await sync_managed_chat(chat_id)
+    if overview["type"] != "channel":
+        raise ChatManagementError("not_channel", "目标不是频道。", status=400)
+    if admin and overview["bot_status"] not in {"owner", "administrator"}:
+        raise ChatManagementError("bot_not_admin", "机器人不是该频道管理员。", status=403)
+    chat = await app.get_chat(chat_id)
+    photo = getattr(chat, "photo", None)
+    overview["photo_file_id"] = getattr(photo, "small_file_id", None)
+    if include_photo and overview["photo_file_id"]:
+        try:
+            content = await app.download_media(overview["photo_file_id"], in_memory=True)
+            overview["photo_data_url"] = "data:image/jpeg;base64," + base64.b64encode(
+                content.getvalue()
+            ).decode("ascii")
+        except Exception:
+            overview["photo_data_url"] = None
+    return overview
+
+
+async def list_channels(*, query: str, page: int, page_size: int) -> tuple[list[dict], int]:
+    return await list_managed_chats(query=query, page=page, page_size=page_size, chat_type="channel")
+
+
+async def update_channel_profile(chat_id: int, *, title: str | None, description: str | None) -> dict:
+    await ensure_channel(chat_id)
+    try:
+        return await update_chat_profile(chat_id, title=title, description=description)
+    except ChatManagementError as exc:
+        raise ChatManagementError(exc.code, str(exc).replace("群", "频道"), status=exc.status) from exc
+
+
+async def update_channel_photo(chat_id: int, photo_path: Path) -> dict:
+    await ensure_channel(chat_id)
+    await ensure_permission(chat_id, "can_change_info")
+    try:
+        await app.set_chat_photo(chat_id, photo=str(photo_path))
+    except RPCError as exc:
+        raise ChatManagementError(
+            "channel_photo_failed", f"Telegram 拒绝更新频道头像:{telegram_error_detail(exc)}", status=502
+        ) from exc
+    except Exception as exc:
+        raise ChatManagementError("channel_photo_failed", "Telegram 未能更新频道头像。", status=502) from exc
+    return await ensure_channel(chat_id, include_photo=True)
+
+
+async def list_channel_admins(chat_id: int) -> list[dict]:
+    await ensure_channel(chat_id)
+    from pyrogram.enums import ChatMembersFilter
+
+    try:
+        return [
+            serialize_member(member)
+            async for member in app.get_chat_members(chat_id, filter=ChatMembersFilter.ADMINISTRATORS)
+        ]
+    except Exception as exc:
+        raise ChatManagementError("admin_list_failed", "Telegram 未能读取频道管理员。", status=502) from exc
+
+
+async def set_channel_admin(chat_id: int, user_id: int, privileges: list[str]) -> dict:
+    await ensure_channel(chat_id)
+    await ensure_permission(chat_id, "can_promote_members")
+    requested = set(privileges)
+    if not requested or requested - CHANNEL_ADMIN_PRIVILEGES:
+        raise ChatManagementError("invalid_privileges", "请选择有效的频道管理员权限。")
+    try:
+        bot = await app.get_chat_member(chat_id, BOT_ID)
+    except Exception as exc:
+        raise ChatManagementError("bot_permission_unavailable", "Telegram 未能读取机器人权限。", status=502) from exc
+    allowed = set(_privilege_list(bot))
+    if bot.status != ChatMemberStatus.OWNER and requested - allowed:
+        raise ChatManagementError("privilege_exceeds_bot", "不能授予超出机器人自身的权限。", status=403)
+    try:
+        target = await app.get_chat_member(chat_id, user_id)
+        if target.status == ChatMemberStatus.OWNER:
+            raise ChatManagementError("owner_protected", "不能修改频道所有者。", status=403)
+        values = {key: key in requested for key in CHANNEL_ADMIN_PRIVILEGES}
+        await app.promote_chat_member(chat_id, user_id, ChatPrivileges(**values))
+    except ChatManagementError:
+        raise
+    except RPCError as exc:
+        raise ChatManagementError(
+            "admin_update_failed", f"Telegram 拒绝调整管理员:{telegram_error_detail(exc)}", status=502
+        ) from exc
+    except Exception as exc:
+        raise ChatManagementError("admin_update_failed", "Telegram 未能调整管理员;请确认用户已订阅频道及机器人有权管理该用户。", status=502) from exc
+    try:
+        return serialize_member(await app.get_chat_member(chat_id, user_id))
+    except Exception:
+        return {"user_id": str(user_id), "updated": True}
+
+
+async def remove_channel_admin(chat_id: int, user_id: int) -> dict:
+    await ensure_channel(chat_id)
+    await ensure_permission(chat_id, "can_promote_members")
+    try:
+        target = await app.get_chat_member(chat_id, user_id)
+    except Exception as exc:
+        raise ChatManagementError("admin_lookup_failed", "Telegram 未能读取该用户的频道身份。", status=502) from exc
+    if target.status == ChatMemberStatus.OWNER:
+        raise ChatManagementError("owner_protected", "不能移除频道所有者。", status=403)
+    if target.status != ChatMemberStatus.ADMINISTRATOR:
+        raise ChatManagementError("not_admin", "该用户不是频道管理员。", status=409)
+    try:
+        await app.promote_chat_member(chat_id, user_id, ChatPrivileges(can_manage_chat=False))
+    except RPCError as exc:
+        raise ChatManagementError(
+            "admin_remove_failed", f"Telegram 拒绝移除管理员:{telegram_error_detail(exc)}", status=502
+        ) from exc
+    except Exception as exc:
+        raise ChatManagementError("admin_remove_failed", "Telegram 未能移除管理员。", status=502) from exc
+    return {"user_id": str(user_id), "removed": True}
+
+
+def validate_post_values(
+    values: dict[str, Any], *, scheduled: bool = False, keep_publish_at: bool = False
+) -> dict[str, Any]:
+    text = str(values.get("text") or "").strip()
+    media_type = values.get("media_type") or None
+    file_id = values.get("file_id") or None
+    if media_type not in (None, *MEDIA_METHODS) or bool(media_type) != bool(file_id):
+        raise ChatManagementError("invalid_media", "媒体类型与文件 ID 不匹配。")
+    if not text and not file_id:
+        raise ChatManagementError("empty_post", "帖子文字或媒体至少填写一项。")
+    if len(text) > (1024 if file_id else 4096):
+        raise ChatManagementError("post_too_long", "文字超过 Telegram 长度限制。")
+    media_filename = str(values.get("media_filename") or "").strip() if file_id else ""
+    media_mime_type = str(values.get("media_mime_type") or "").strip() if file_id else ""
+    if len(media_filename) > 255 or len(media_mime_type) > 100:
+        raise ChatManagementError("invalid_media_metadata", "媒体文件信息过长。")
+    try:
+        media_size = int(values["media_size"]) if file_id and values.get("media_size") is not None else None
+    except (TypeError, ValueError) as exc:
+        raise ChatManagementError("invalid_media_metadata", "媒体文件大小无效。") from exc
+    if media_size is not None and media_size < 0:
+        raise ChatManagementError("invalid_media_metadata", "媒体文件大小无效。")
+    publish_at = values.get("publish_at")
+    if publish_at is not None:
+        if isinstance(publish_at, str):
+            try:
+                publish_at = datetime.fromisoformat(publish_at.replace("Z", "+00:00"))
+            except ValueError as exc:
+                raise ChatManagementError("invalid_publish_at", "定时时间无效。") from exc
+            if publish_at.tzinfo is None:
+                raise ChatManagementError("invalid_publish_at", "定时时间须包含时区。")
+        if not isinstance(publish_at, datetime):
+            raise ChatManagementError("invalid_publish_at", "定时时间须包含时区。")
+        if publish_at.tzinfo is None:
+            publish_at = publish_at.replace(tzinfo=UTC)
+        publish_at = publish_at.astimezone(UTC)
+        if not keep_publish_at and publish_at <= now_utc() + timedelta(seconds=5):
+            raise ChatManagementError("invalid_publish_at", "定时时间至少晚于当前 5 秒。")
+    elif scheduled:
+        raise ChatManagementError("invalid_publish_at", "请填写定时时间。")
+    return {
+        "text": text,
+        "media_type": media_type,
+        "file_id": file_id,
+        "media_filename": media_filename or None,
+        "media_size": media_size,
+        "media_mime_type": media_mime_type or None,
+        "pin": bool(values.get("pin", False)),
+        "publish_at": publish_at,
+    }
+
+
+def same_publish_at(value: Any, current: Any) -> bool:
+    if isinstance(value, str):
+        try:
+            value = datetime.fromisoformat(value.replace("Z", "+00:00"))
+        except ValueError:
+            return False
+    if not isinstance(value, datetime) or not isinstance(current, datetime):
+        return False
+    value = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
+    current = current.replace(tzinfo=UTC) if current.tzinfo is None else current.astimezone(UTC)
+    return value == current
+
+
+async def publish_post(post: dict[str, Any]) -> dict:
+    if not has_permission("channel.posts", BOT_PERMISSIONS):
+        raise ChatManagementError("bot_role_permission_denied", "所选机器人的职责角色不允许发布频道帖子。", status=403)
+    chat_id = int(post["chat_id"])
+    await ensure_channel(chat_id)
+    await ensure_permission(chat_id, "can_post_messages")
+    if post.get("pin"):
+        await ensure_permission(chat_id, "can_edit_messages")
+    try:
+        if post.get("file_id"):
+            method_name, keyword, _ = MEDIA_METHODS[post["media_type"]]
+            sent = await getattr(app, method_name)(
+                chat_id, **{keyword: post["file_id"]}, caption=post["text"] or None,
+                parse_mode=ParseMode.DISABLED,
+            )
+        else:
+            sent = await app.send_message(chat_id, post["text"], parse_mode=ParseMode.DISABLED)
+    except RPCError as exc:
+        detail = telegram_error_detail(exc)
+        await postsdb.update_one(
+            {"post_id": post["post_id"], "status": "sending"},
+            {"$set": {"status": "failed", "last_error": detail, "updated_at": now_utc()}},
+        )
+        raise ChatManagementError("post_send_failed", f"Telegram 拒绝发布:{detail}", status=502) from exc
+    except Exception as exc:
+        await postsdb.update_one(
+            {"post_id": post["post_id"], "status": "sending"},
+            {"$set": {"status": "uncertain", "last_error": "Telegram 发送返回异常,结果需要人工核实。", "updated_at": now_utc()}},
+        )
+        raise ChatManagementError(
+            "post_delivery_uncertain", "Telegram 发送结果不明,已停止自动重试;请在频道核实。", status=502
+        ) from exc
+
+    key = telegram_key(chat_id, int(sent.id))
+    try:
+        for attempt in range(3):
+            await postsdb.delete_one({"telegram_key": key, "source": "telegram"})
+            try:
+                await postsdb.update_one(
+                    {"post_id": post["post_id"]},
+                    {"$set": {
+                        "telegram_key": key,
+                        "message_id": int(sent.id),
+                        "status": "published",
+                        "published_at": now_utc(),
+                        "updated_at": now_utc(),
+                    }},
+                )
+                break
+            except DuplicateKeyError:
+                if attempt == 2:
+                    raise
+    except Exception as exc:
+        raise ChatManagementError(
+            "post_delivery_uncertain", "消息已发出,但记录更新失败;请在频道核实,系统不会自动重发。", status=502
+        ) from exc
+    if post.get("pin"):
+        try:
+            await app.pin_chat_message(chat_id, int(sent.id))
+        except Exception:
+            await postsdb.update_one(
+                {"post_id": post["post_id"]},
+                {"$set": {"pin_error": "Telegram 未能置顶该消息。"}},
+            )
+    return public_post(await get_post(chat_id, post["post_id"]))
+
+
+async def create_channel_post(chat_id: int, values: dict[str, Any]) -> dict:
+    await ensure_channel(chat_id)
+    await ensure_permission(chat_id, "can_post_messages")
+    data = validate_post_values(values)
+    if data["pin"]:
+        await ensure_permission(chat_id, "can_edit_messages")
+    post = await create_post(chat_id, **data)
+    if data["publish_at"]:
+        return public_post(post)
+    try:
+        return await publish_post(post)
+    except ChatManagementError as exc:
+        if exc.code != "post_delivery_uncertain":
+            await postsdb.update_one(
+                {"post_id": post["post_id"], "status": "sending"},
+                {"$set": {"status": "failed", "last_error": str(exc)[:300], "updated_at": now_utc()}},
+            )
+        raise
+
+
+async def edit_channel_post(chat_id: int, post_id: str, values: dict[str, Any]) -> dict:
+    await ensure_channel(chat_id)
+    current = await get_post(chat_id, post_id)
+    if not current:
+        raise ChatManagementError("post_not_found", "帖子不存在。", status=404)
+    merged = {key: current.get(key) for key in (
+        "text", "media_type", "file_id", "media_filename", "media_size", "media_mime_type", "pin", "publish_at"
+    )}
+    if current["status"] != "scheduled":
+        merged["publish_at"] = None
+    merged.update(values)
+    if values.get("file_id") and values["file_id"] != current.get("file_id"):
+        for key in ("media_filename", "media_size", "media_mime_type"):
+            if key not in values:
+                merged[key] = None
+    data = validate_post_values(
+        merged,
+        scheduled=current["status"] == "scheduled",
+        keep_publish_at=current["status"] == "scheduled" and (
+            "publish_at" not in values or same_publish_at(values["publish_at"], current.get("publish_at"))
+        ),
+    )
+    if current["status"] == "scheduled":
+        await ensure_permission(chat_id, "can_post_messages")
+        updated = await update_scheduled_post(chat_id, post_id, data)
+        if not updated:
+            raise ChatManagementError("post_in_progress", "帖子已进入发送流程,不能编辑。", status=409)
+        return public_post(updated)
+    if current["status"] != "published":
+        raise ChatManagementError("post_not_editable", "当前状态不能编辑帖子。", status=409)
+    if "publish_at" in values or "pin" in values:
+        raise ChatManagementError("invalid_edit", "已发布帖子不能修改定时或置顶设置。")
+    await ensure_permission(chat_id, "can_edit_messages")
+    message_id = int(current["message_id"])
+    try:
+        if data["media_type"]:
+            if not current.get("media_type"):
+                raise ChatManagementError("media_change_unsupported", "文字帖不能改为媒体帖。")
+            if data["file_id"] != current.get("file_id") or data["media_type"] != current.get("media_type"):
+                _, _, media_class = MEDIA_METHODS[data["media_type"]]
+                await app.edit_message_media(
+                    chat_id, message_id, media_class(
+                        data["file_id"], caption=data["text"], parse_mode=ParseMode.DISABLED
+                    )
+                )
+            else:
+                await app.edit_message_caption(
+                    chat_id, message_id, data["text"], parse_mode=ParseMode.DISABLED
+                )
+        else:
+            if current.get("media_type"):
+                raise ChatManagementError("media_change_unsupported", "媒体帖不能改为纯文字帖。")
+            await app.edit_message_text(
+                chat_id, message_id, data["text"], parse_mode=ParseMode.DISABLED
+            )
+    except ChatManagementError:
+        raise
+    except RPCError as exc:
+        raise ChatManagementError(
+            "post_edit_failed", f"Telegram 拒绝编辑帖子:{telegram_error_detail(exc)}", status=502
+        ) from exc
+    except Exception as exc:
+        raise ChatManagementError("post_edit_failed", "Telegram 未能编辑帖子;请检查 Bot 权限与消息状态。", status=502) from exc
+    await postsdb.update_one(
+        {"post_id": post_id, "bot_id": BOT_PROFILE_ID},
+        {"$set": {key: data[key] for key in (
+            "text", "media_type", "file_id", "media_filename", "media_size", "media_mime_type"
+        )} | {"updated_at": now_utc()}},
+    )
+    return public_post(await get_post(chat_id, post_id))
+
+
+async def delete_channel_post(chat_id: int, post_id: str) -> dict:
+    await ensure_channel(chat_id)
+    current = await get_post(chat_id, post_id)
+    if not current:
+        raise ChatManagementError("post_not_found", "帖子不存在。", status=404)
+    if current["status"] == "scheduled":
+        updated = await update_scheduled_post(chat_id, post_id, {"status": "canceled"})
+        if not updated:
+            raise ChatManagementError("post_in_progress", "帖子已进入发送流程,不能取消。", status=409)
+        return public_post(updated)
+    if current["status"] != "published":
+        raise ChatManagementError("post_not_deletable", "当前状态不能删除帖子。", status=409)
+    await ensure_permission(chat_id, "can_delete_messages")
+    try:
+        deleted = await app.delete_messages(chat_id, int(current["message_id"]))
+        if deleted == 0:
+            raise RuntimeError("Telegram did not delete the message")
+    except RPCError as exc:
+        raise ChatManagementError(
+            "post_delete_failed", f"Telegram 拒绝删除帖子:{telegram_error_detail(exc)}", status=502
+        ) from exc
+    except Exception as exc:
+        raise ChatManagementError("post_delete_failed", "Telegram 未能删除帖子;请检查 Bot 权限与消息状态。", status=502) from exc
+    await postsdb.update_one(
+        {"post_id": post_id, "bot_id": BOT_PROFILE_ID},
+        {"$set": {"status": "deleted", "updated_at": now_utc()}},
+    )
+    return public_post(await get_post(chat_id, post_id))
+
+
+async def observe_channel_post(message: Any) -> None:
+    if getattr(message.chat, "type", None) != ChatType.CHANNEL:
+        return
+    chat_id, message_id = int(message.chat.id), int(message.id)
+    bot = await app.get_chat_member(chat_id, BOT_ID)
+    if bot.status not in {ChatMemberStatus.OWNER, ChatMemberStatus.ADMINISTRATOR}:
+        return
+    await upsert_managed_chat(
+        chat_id=chat_id,
+        title=message.chat.title,
+        username=message.chat.username,
+        chat_type="channel",
+        bot_status=str(getattr(bot.status, "value", bot.status)),
+        bot_privileges=_privilege_list(bot),
+    )
+    media_type = next((name for name in MEDIA_METHODS if getattr(message, name, None)), None)
+    media = getattr(message, media_type, None) if media_type else None
+    key = telegram_key(chat_id, message_id)
+    values = {
+        "text": getattr(message, "text", None) or getattr(message, "caption", None) or "",
+        "media_type": media_type or ("other" if getattr(message, "media", None) else None),
+        "file_id": getattr(media, "file_id", None),
+        "media_filename": getattr(media, "file_name", None),
+        "media_size": getattr(media, "file_size", None),
+        "media_mime_type": getattr(media, "mime_type", None),
+        "updated_at": now_utc(),
+    }
+    await ensure_channel_indexes()
+    update = {
+            "$set": values,
+            "$setOnInsert": {
+                "post_id": uuid4().hex,
+                "bot_id": BOT_PROFILE_ID,
+                "chat_id": chat_id,
+                "telegram_key": key,
+                "message_id": message_id,
+                "source": "telegram",
+                "status": "published",
+                "pin": False,
+                "created_at": getattr(message, "date", None) or now_utc(),
+                "published_at": getattr(message, "date", None) or now_utc(),
+            },
+        }
+    try:
+        await postsdb.update_one({"telegram_key": key}, update, upsert=True)
+    except DuplicateKeyError:
+        await postsdb.update_one({"telegram_key": key}, {"$set": values})
+
+
+async def observe_channel_deletion(chat_id: int, message_id: int) -> None:
+    await ensure_channel_indexes()
+    await postsdb.update_one(
+        {"telegram_key": telegram_key(chat_id, message_id)},
+        {"$set": {"status": "deleted", "updated_at": now_utc()}},
+    )
+
+
+async def sweep_channel_posts() -> None:
+    await mark_stale_sends_uncertain()
+    for post in await claim_due_posts():
+        try:
+            await publish_post(post)
+        except Exception as exc:
+            if isinstance(exc, ChatManagementError) and exc.code != "post_delivery_uncertain":
+                await postsdb.update_one(
+                    {"post_id": post["post_id"], "status": "sending"},
+                    {"$set": {"status": "failed", "last_error": str(exc)[:300], "updated_at": now_utc()}},
+                )
+            try:
+                await record_audit(
+                    source="system", actor_id=None, actor_name="频道定时发布",
+                    action="channel.post.publish", chat_id=int(post["chat_id"]),
+                    target_id=post["post_id"], summary="定时发布失败或结果不明",
+                    success=False, error=str(exc)[:300],
+                )
+            except Exception as audit_exc:
+                log.error(f"频道定时发布审计写入失败:{audit_exc}")
+        else:
+            try:
+                await record_audit(
+                    source="system", actor_id=None, actor_name="频道定时发布",
+                    action="channel.post.publish", chat_id=int(post["chat_id"]),
+                    target_id=post["post_id"], summary="定时帖子已发布",
+                )
+            except Exception as audit_exc:
+                log.error(f"频道定时发布审计写入失败:{audit_exc}")

+ 3 - 3
wbb/services/chat_management.py

@@ -174,7 +174,7 @@ async def sync_managed_chat(chat_id: int) -> dict[str, Any]:
 async def hydrate_managed_chats() -> None:
     if not TELEGRAM_CONNECTED:
         return
-    _, total = await list_managed_chats(page=1, page_size=1)
+    _, total = await list_managed_chats(page=1, page_size=1, chat_type="group")
     if total:
         return
     if BOT_PROFILE_ID != "primary":
@@ -198,12 +198,12 @@ async def list_accessible_chats(
 ) -> tuple[list[dict[str, Any]], int]:
     await hydrate_managed_chats()
     if actor_id is None or actor_id in SUDOERS:
-        return await list_managed_chats(query=query, page=page, page_size=page_size)
+        return await list_managed_chats(query=query, page=page, page_size=page_size, chat_type="group")
     all_items: list[dict[str, Any]] = []
     source_page = 1
     while True:
         batch, source_total = await list_managed_chats(
-            query=query, page=source_page, page_size=100
+            query=query, page=source_page, page_size=100, chat_type="group"
         )
         all_items.extend(batch)
         if not batch or len(all_items) >= source_total:

+ 15 - 4
wbb/utils/dbadmin.py

@@ -204,7 +204,7 @@ async def upsert_managed_chat(
     username: str | None,
     chat_type: str,
     member_count: int | None = None,
-    accessible: bool = True,
+    accessible: bool | None = True,
     bot_status: str | None = None,
     bot_privileges: list[str] | None = None,
 ) -> None:
@@ -214,10 +214,11 @@ async def upsert_managed_chat(
         "title": title or str(chat_id),
         "username": username,
         "type": chat_type,
-        "accessible": accessible,
         "last_seen_at": now,
         "updated_at": now,
     }
+    if accessible is not None:
+        values["accessible"] = accessible
     if member_count is not None:
         values["member_count"] = int(member_count)
     if bot_status is not None:
@@ -228,7 +229,11 @@ async def upsert_managed_chat(
         {"bot_id": BOT_PROFILE_ID, "chat_id": chat_id},
         {
             "$set": values,
-            "$setOnInsert": {"bot_id": BOT_PROFILE_ID, "created_at": now},
+            "$setOnInsert": {
+                "bot_id": BOT_PROFILE_ID,
+                "created_at": now,
+                **({"accessible": True} if accessible is None else {}),
+            },
         },
         upsert=True,
     )
@@ -273,10 +278,16 @@ async def managed_chat_exists(chat_id: int) -> bool:
 
 
 async def list_managed_chats(
-    *, query: str = "", page: int = 1, page_size: int = 20
+    *, query: str = "", page: int = 1, page_size: int = 20,
+    chat_type: str | None = None,
 ) -> tuple[list[dict[str, Any]], int]:
     await ensure_admin_indexes()
     filters: dict[str, Any] = {"bot_id": BOT_PROFILE_ID}
+    if chat_type == "channel":
+        filters["type"] = "channel"
+        filters["bot_status"] = {"$in": ["owner", "administrator"]}
+    elif chat_type == "group":
+        filters["type"] = {"$in": ["group", "supergroup"]}
     query = query.strip()
     if query:
         if query.lstrip("-").isdigit():

+ 138 - 0
wbb/utils/dbchannel.py

@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from uuid import uuid4
+
+from pymongo import ASCENDING, DESCENDING, ReturnDocument
+
+from wbb import BOT_PROFILE_ID, db
+
+postsdb = db.channel_posts
+_indexes_ready = False
+
+
+def now_utc() -> datetime:
+    return datetime.now(UTC)
+
+
+async def ensure_channel_indexes() -> None:
+    global _indexes_ready
+    if _indexes_ready:
+        return
+    await postsdb.create_index("post_id", unique=True)
+    await postsdb.create_index("telegram_key", unique=True, sparse=True)
+    await postsdb.create_index(
+        [("bot_id", ASCENDING), ("chat_id", ASCENDING), ("created_at", DESCENDING)]
+    )
+    await postsdb.create_index(
+        [("bot_id", ASCENDING), ("status", ASCENDING), ("publish_at", ASCENDING)]
+    )
+    _indexes_ready = True
+
+
+def telegram_key(chat_id: int, message_id: int) -> str:
+    return f"{BOT_PROFILE_ID}:{chat_id}:{message_id}"
+
+
+async def create_post(
+    chat_id: int,
+    *,
+    text: str,
+    media_type: str | None,
+    file_id: str | None,
+    media_filename: str | None,
+    media_size: int | None,
+    media_mime_type: str | None,
+    pin: bool,
+    publish_at: datetime | None,
+) -> dict:
+    await ensure_channel_indexes()
+    created = now_utc()
+    post = {
+        "post_id": uuid4().hex,
+        "bot_id": BOT_PROFILE_ID,
+        "chat_id": int(chat_id),
+        "source": "admin",
+        "status": "scheduled" if publish_at else "sending",
+        "text": text,
+        "media_type": media_type,
+        "file_id": file_id,
+        "media_filename": media_filename,
+        "media_size": media_size,
+        "media_mime_type": media_mime_type,
+        "pin": bool(pin),
+        "publish_at": publish_at,
+        "message_id": None,
+        "created_at": created,
+        "updated_at": created,
+    }
+    await postsdb.insert_one(post)
+    return post
+
+
+async def get_post(chat_id: int, post_id: str) -> dict | None:
+    await ensure_channel_indexes()
+    return await postsdb.find_one(
+        {"bot_id": BOT_PROFILE_ID, "chat_id": int(chat_id), "post_id": post_id}
+    )
+
+
+async def list_posts(chat_id: int, *, page: int, page_size: int) -> tuple[list[dict], int]:
+    await ensure_channel_indexes()
+    query = {"bot_id": BOT_PROFILE_ID, "chat_id": int(chat_id)}
+    total = await postsdb.count_documents(query)
+    cursor = (
+        postsdb.find(query)
+        .sort("created_at", DESCENDING)
+        .skip((page - 1) * page_size)
+        .limit(page_size)
+    )
+    return [item async for item in cursor], total
+
+
+async def update_scheduled_post(chat_id: int, post_id: str, values: dict) -> dict | None:
+    await ensure_channel_indexes()
+    return await postsdb.find_one_and_update(
+        {
+            "bot_id": BOT_PROFILE_ID,
+            "chat_id": int(chat_id),
+            "post_id": post_id,
+            "status": "scheduled",
+        },
+        {"$set": {**values, "updated_at": now_utc()}},
+        return_document=ReturnDocument.AFTER,
+    )
+
+
+async def claim_due_posts(limit: int = 20) -> list[dict]:
+    await ensure_channel_indexes()
+    deadline = now_utc()
+    candidates = postsdb.find(
+        {
+            "bot_id": BOT_PROFILE_ID,
+            "status": "scheduled",
+            "publish_at": {"$lte": deadline},
+        }
+    ).sort("publish_at", ASCENDING).limit(limit)
+    claimed: list[dict] = []
+    async for candidate in candidates:
+        item = await postsdb.find_one_and_update(
+            {"post_id": candidate["post_id"], "status": "scheduled"},
+            {"$set": {"status": "sending", "updated_at": now_utc()}},
+            return_document=ReturnDocument.AFTER,
+        )
+        if item:
+            claimed.append(item)
+    return claimed
+
+
+async def mark_stale_sends_uncertain() -> None:
+    await ensure_channel_indexes()
+    await postsdb.update_many(
+        {
+            "bot_id": BOT_PROFILE_ID,
+            "status": "sending",
+            "updated_at": {"$lt": now_utc() - timedelta(minutes=5)},
+        },
+        {"$set": {"status": "uncertain", "updated_at": now_utc()}},
+    )