Przeglądaj źródła

feat: add channel management and scheduled publishing

AI-Co-Authored-By: Codex
chendeben 5 dni temu
rodzic
commit
a79925a3e8

+ 14 - 0
README.md

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

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

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

+ 191 - 4
wbb/admin/api.py

@@ -41,12 +41,25 @@ from wbb.admin.telegram_webapp import (
     TelegramWebAppAuthError,
     TelegramWebAppAuthError,
     verify_telegram_webapp_init_data,
     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 (
 from wbb.services.business_assistant import (
     AssistantProviderError,
     AssistantProviderError,
     FeishuWebhookError,
     FeishuWebhookError,
     runtime_overview,
     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 (
 from wbb.services.chat_management import (
     ChatManagementError,
     ChatManagementError,
     apply_automation_settings,
     apply_automation_settings,
@@ -158,6 +171,7 @@ from wbb.utils.dbassistant import (
     update_knowledge_source,
     update_knowledge_source,
     usage_metrics,
     usage_metrics,
 )
 )
+from wbb.utils.dbchannel import list_posts
 from wbb.utils.dbdirectory import (
 from wbb.utils.dbdirectory import (
     APPLICATION_APPROVED,
     APPLICATION_APPROVED,
     DirectoryDataError,
     DirectoryDataError,
@@ -235,6 +249,7 @@ PUBLIC_API_PATHS = {f"{API_PREFIX}/auth/login", f"{API_PREFIX}/health"}
 BOT_SCOPED_PREFIXES = (
 BOT_SCOPED_PREFIXES = (
     f"{API_PREFIX}/business-assistant",
     f"{API_PREFIX}/business-assistant",
     f"{API_PREFIX}/chats",
     f"{API_PREFIX}/chats",
+    f"{API_PREFIX}/channels",
     f"{API_PREFIX}/giveaways",
     f"{API_PREFIX}/giveaways",
     f"{API_PREFIX}/points",
     f"{API_PREFIX}/points",
     f"{API_PREFIX}/media",
     f"{API_PREFIX}/media",
@@ -623,7 +638,15 @@ async def bot_role_middleware(request: web.Request, handler):
         permissions = profile.get("permissions", [])
         permissions = profile.get("permissions", [])
     else:
     else:
         permissions = getattr(wbb, "BOT_PERMISSIONS", {"*"})
         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(
         raise ApiProblem(
             "bot_role_permission_denied",
             "bot_role_permission_denied",
             "所选机器人的职责角色不允许执行该操作。",
             "所选机器人的职责角色不允许执行该操作。",
@@ -760,6 +783,22 @@ class AdminApi:
         router.add_get(f"{API_PREFIX}/audit-logs", self.audit_logs)
         router.add_get(f"{API_PREFIX}/audit-logs", self.audit_logs)
         router.add_post(f"{API_PREFIX}/media", self.upload_media)
         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(
         router.add_get(
             f"{API_PREFIX}/business-assistant/status",
             f"{API_PREFIX}/business-assistant/status",
             self.business_assistant_status,
             self.business_assistant_status,
@@ -2136,9 +2175,12 @@ class AdminApi:
                         raise ApiProblem("file_too_large", "上传文件超过 20 MB 限制。", status=413)
                         raise ApiProblem("file_too_large", "上传文件超过 20 MB 限制。", status=413)
                     handle.write(chunk)
                     handle.write(chunk)
             if content_type == "image/gif":
             if content_type == "image/gif":
-                media_type = "animation"
                 sent = await telegram_app.send_animation(MESSAGE_DUMP_CHAT, str(temporary_path))
                 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/"):
             elif content_type.startswith("image/"):
                 media_type = "photo"
                 media_type = "photo"
                 sent = await telegram_app.send_photo(MESSAGE_DUMP_CHAT, str(temporary_path))
                 sent = await telegram_app.send_photo(MESSAGE_DUMP_CHAT, str(temporary_path))
@@ -2163,10 +2205,155 @@ class AdminApi:
                 "type": media_type,
                 "type": media_type,
                 "filename": filename,
                 "filename": filename,
                 "size": size,
                 "size": size,
+                "mime_type": content_type,
             },
             },
             status=201,
             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:
     async def chats(self, request: web.Request) -> web.Response:
         page, page_size = page_params(request)
         page, page_size = page_params(request)
         items, total = await list_accessible_chats(
         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
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 SOFTWARE.
 """
 """
+from pyrogram.enums import ChatType
+
 from wbb import BOT_PERMISSIONS, app
 from wbb import BOT_PERMISSIONS, app
 from wbb.services.bot_permissions import has_permission
 from wbb.services.bot_permissions import has_permission
 from wbb.services.directory import observe_group_member
 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)
         return await app.leave_chat(chat_id)
 
 
     await add_served_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(
         if has_permission("automation.manage", BOT_PERMISSIONS) or has_permission(
             "teacher_directory.manage", BOT_PERMISSIONS
             "teacher_directory.manage", BOT_PERMISSIONS
         ):
         ):

+ 22 - 0
wbb/services/bot_permissions.py

@@ -28,6 +28,10 @@ PERMISSIONS = (
     BotPermission("chat.invites", "邀请链接", "群组管理", "创建和撤销邀请链接。"),
     BotPermission("chat.invites", "邀请链接", "群组管理", "创建和撤销邀请链接。"),
     BotPermission("chat.rules", "群规", "群组管理", "查看和维护群规。"),
     BotPermission("chat.rules", "群规", "群组管理", "查看和维护群规。"),
     BotPermission("chat.notes", "群笔记", "群组管理", "维护群内笔记功能。"),
     BotPermission("chat.notes", "群笔记", "群组管理", "维护群内笔记功能。"),
+    BotPermission("channel.profile", "频道资料", "频道管理", "接入频道并维护名称、简介和头像。"),
+    BotPermission("channel.admins", "频道管理员", "频道管理", "查看和调整频道管理员。"),
+    BotPermission("channel.invites", "频道邀请链接", "频道管理", "创建和撤销频道邀请链接。"),
+    BotPermission("channel.posts", "频道帖子", "频道管理", "发布、定时、编辑和删除频道帖子。"),
     BotPermission(
     BotPermission(
         "automation.manage",
         "automation.manage",
         "自动化与风控",
         "自动化与风控",
@@ -89,6 +93,12 @@ BUILTIN_ROLES = (
             "chat.notes",
             "chat.notes",
         ),
         ),
     ),
     ),
+    BuiltinBotRole(
+        "channel_manager",
+        "频道管理员",
+        "负责频道资料、管理员、邀请链接和帖子。",
+        ("channel.profile", "channel.admins", "channel.invites", "channel.posts", "media.upload"),
+    ),
     BuiltinBotRole(
     BuiltinBotRole(
         "automation_manager",
         "automation_manager",
         "自动化与风控专员",
         "自动化与风控专员",
@@ -136,6 +146,7 @@ MODULE_PERMISSIONS: dict[str, frozenset[str]] = {
     "blacklist_chat": frozenset({"automation.manage"}),
     "blacklist_chat": frozenset({"automation.manage"}),
     "business_assistant": frozenset({"business_assistant.manage"}),
     "business_assistant": frozenset({"business_assistant.manage"}),
     "chatbot": frozenset({"automation.manage"}),
     "chatbot": frozenset({"automation.manage"}),
+    "channels": frozenset({"channel.profile", "channel.admins", "channel.invites", "channel.posts"}),
     "filters": frozenset({"automation.manage"}),
     "filters": frozenset({"automation.manage"}),
     "flood": frozenset({"automation.manage"}),
     "flood": frozenset({"automation.manage"}),
     "greetings": 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:
 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"):
     if path.startswith("/api/admin/v1/business-assistant"):
         return "business_assistant.manage"
         return "business_assistant.manage"
     if path.startswith("/api/admin/v1/directory"):
     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:
 async def hydrate_managed_chats() -> None:
     if not TELEGRAM_CONNECTED:
     if not TELEGRAM_CONNECTED:
         return
         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:
     if total:
         return
         return
     if BOT_PROFILE_ID != "primary":
     if BOT_PROFILE_ID != "primary":
@@ -198,12 +198,12 @@ async def list_accessible_chats(
 ) -> tuple[list[dict[str, Any]], int]:
 ) -> tuple[list[dict[str, Any]], int]:
     await hydrate_managed_chats()
     await hydrate_managed_chats()
     if actor_id is None or actor_id in SUDOERS:
     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]] = []
     all_items: list[dict[str, Any]] = []
     source_page = 1
     source_page = 1
     while True:
     while True:
         batch, source_total = await list_managed_chats(
         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)
         all_items.extend(batch)
         if not batch or len(all_items) >= source_total:
         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,
     username: str | None,
     chat_type: str,
     chat_type: str,
     member_count: int | None = None,
     member_count: int | None = None,
-    accessible: bool = True,
+    accessible: bool | None = True,
     bot_status: str | None = None,
     bot_status: str | None = None,
     bot_privileges: list[str] | None = None,
     bot_privileges: list[str] | None = None,
 ) -> None:
 ) -> None:
@@ -214,10 +214,11 @@ async def upsert_managed_chat(
         "title": title or str(chat_id),
         "title": title or str(chat_id),
         "username": username,
         "username": username,
         "type": chat_type,
         "type": chat_type,
-        "accessible": accessible,
         "last_seen_at": now,
         "last_seen_at": now,
         "updated_at": now,
         "updated_at": now,
     }
     }
+    if accessible is not None:
+        values["accessible"] = accessible
     if member_count is not None:
     if member_count is not None:
         values["member_count"] = int(member_count)
         values["member_count"] = int(member_count)
     if bot_status is not None:
     if bot_status is not None:
@@ -228,7 +229,11 @@ async def upsert_managed_chat(
         {"bot_id": BOT_PROFILE_ID, "chat_id": chat_id},
         {"bot_id": BOT_PROFILE_ID, "chat_id": chat_id},
         {
         {
             "$set": values,
             "$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,
         upsert=True,
     )
     )
@@ -273,10 +278,16 @@ async def managed_chat_exists(chat_id: int) -> bool:
 
 
 
 
 async def list_managed_chats(
 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]:
 ) -> tuple[list[dict[str, Any]], int]:
     await ensure_admin_indexes()
     await ensure_admin_indexes()
     filters: dict[str, Any] = {"bot_id": BOT_PROFILE_ID}
     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()
     query = query.strip()
     if query:
     if query:
         if query.lstrip("-").isdigit():
         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()}},
+    )