From 28a0652820b3f80ef1d4eaa1bab14f9b38a11187 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 29 Aug 2026 02:30:19 +0800 Subject: [PATCH 1/2] fix: preserve feed history during source maintenance --- feed_runtime/backend.py | 57 ++++++++++++++++++++- mcp/src/mcp_bridge.py | 2 +- mcp/tests/test_runtime_paths.py | 88 ++++++++++++++++++++++++++++++++- plugin.py | 2 +- skills/feed-manage/SKILL.md | 13 +++++ tests/test_plugin.py | 2 +- 6 files changed, 159 insertions(+), 5 deletions(-) diff --git a/feed_runtime/backend.py b/feed_runtime/backend.py index c8a7c10..fcb6fa4 100644 --- a/feed_runtime/backend.py +++ b/feed_runtime/backend.py @@ -1019,6 +1019,7 @@ def feed_manage(action: str, name: str = "", url: str = "", source_type: str = " cfg = load_config() conn = _connect(cfg) try: + action = action.strip().lower() if action == "list": rows = conn.execute( "SELECT name, type, url, enabled, note FROM sources ORDER BY added_at DESC" @@ -1085,7 +1086,61 @@ def feed_manage(action: str, name: str = "", url: str = "", source_type: str = " ) conn.commit() return f"已订阅 {name.strip()!r}(类型={source_type or 'rss'} {normalized_url}),下次主动巡检时开始收集" - return "错误:action 必须是 subscribe|list|unsubscribe" + + if action in {"pause", "resume", "update"}: + exact_name = name.strip() + if not exact_name: + return f"错误:{action} 需要 name" + rows = conn.execute( + "SELECT id, name, url, enabled FROM sources WHERE lower(name) = lower(?)", + (exact_name,), + ).fetchall() + if not rows: + return f"没有找到名称为 {exact_name!r} 的订阅" + if len(rows) != 1: + return f"错误:名称 {exact_name!r} 对应多个订阅,请先消除重名" + source = rows[0] + source_id = str(source["id"]) + now = _now().isoformat() + + if action in {"pause", "resume"}: + enabled = 0 if action == "pause" else 1 + conn.execute( + "UPDATE sources SET enabled = ?, updated_at = ? WHERE id = ?", + (enabled, now, source_id), + ) + conn.commit() + verb = "暂停" if action == "pause" else "恢复" + return f"已{verb}订阅 {str(source['name'])!r};历史内容和 ack 记录均已保留" + + if not url.strip(): + return "错误:update 需要 url" + normalized_url = _normalize_source_url(url) + duplicate = conn.execute( + "SELECT name FROM sources WHERE url = ? AND id != ? LIMIT 1", + (normalized_url, source_id), + ).fetchone() + if duplicate is not None: + return f"该地址已被订阅 {str(duplicate['name'])!r} 使用" + conn.execute( + "UPDATE sources SET url = ?, updated_at = ? WHERE id = ?", + (normalized_url, now, source_id), + ) + conn.execute( + """ + UPDATE poll_state + SET last_polled_at = NULL, last_success_at = NULL, last_error = NULL + WHERE source_id = ? + """, + (source_id,), + ) + conn.commit() + return ( + f"已更新订阅 {str(source['name'])!r}:{str(source['url'])} -> {normalized_url};" + "历史内容和 ack 记录均已保留" + ) + + return "错误:action 必须是 subscribe|list|unsubscribe|pause|resume|update" finally: conn.close() diff --git a/mcp/src/mcp_bridge.py b/mcp/src/mcp_bridge.py index 0c0d858..2f38e56 100644 --- a/mcp/src/mcp_bridge.py +++ b/mcp/src/mcp_bridge.py @@ -31,7 +31,7 @@ def feed_manage( source_type: str = "rss", note: str = "", ) -> str: - """管理 RSS 订阅源:添加、删除、列出订阅。""" + """管理 RSS 订阅源:添加、删除、列出、暂停、恢复或更新地址。""" return _live_backend().feed_manage( action=action, diff --git a/mcp/tests/test_runtime_paths.py b/mcp/tests/test_runtime_paths.py index 3092944..2fdae64 100644 --- a/mcp/tests/test_runtime_paths.py +++ b/mcp/tests/test_runtime_paths.py @@ -27,11 +27,97 @@ def test_runtime_entrypoints_reject_missing_data_dir( def test_v3_module_keeps_skill_root_and_identity_exports() -> None: assert plugin.api_version == 3 assert plugin.name == "feed" - assert plugin.version == "3.1.3" + assert plugin.version == "3.1.4" assert plugin.skill_roots == ("skills",) assert _config_path() == Path(__file__).resolve().parents[1] / "feed_mcp.json" +def test_feed_manage_pause_resume_and_update_preserve_history( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AKA_PLUGIN_DATA_DIR", str(tmp_path)) + assert "已订阅" in feed_backend.feed_manage( + action="subscribe", + name="OpenAI Blog", + url="http://localhost:1200/openai/news", + ) + config = feed_backend.load_config() + connection = feed_backend._connect(config) + try: + source = connection.execute( + "SELECT id FROM sources WHERE name = 'OpenAI Blog'" + ).fetchone() + assert source is not None + source_id = str(source["id"]) + connection.execute( + """ + INSERT INTO items ( + event_id, source_id, source_name, source_type, title, content, + first_seen_at, last_seen_at, content_hash + ) VALUES ('event-1', ?, 'OpenAI Blog', 'rss', 'title', 'content', + '2026-08-29T00:00:00+00:00', '2026-08-29T00:00:00+00:00', 'hash') + """, + (source_id,), + ) + connection.execute( + """ + INSERT INTO acked_items (event_id, acked_at, expires_at) + VALUES ('event-1', '2026-08-29T00:00:00+00:00', '2026-08-30T00:00:00+00:00') + """ + ) + connection.execute( + """ + INSERT INTO poll_state (source_id, last_polled_at, last_success_at, last_error) + VALUES (?, 'old-poll', 'old-success', 'old-error') + """, + (source_id,), + ) + connection.commit() + finally: + connection.close() + + assert "已暂停" in feed_backend.feed_manage(action="pause", name="OpenAI Blog") + assert "已恢复" in feed_backend.feed_manage(action="resume", name="openai blog") + result = feed_backend.feed_manage( + action="update", + name="OpenAI Blog", + url="http://rsshub:1200/openai/news", + ) + assert "已更新" in result + + connection = feed_backend._connect(config) + try: + source = connection.execute( + "SELECT url, enabled FROM sources WHERE id = ?", (source_id,) + ).fetchone() + assert source is not None + assert source["url"] == "http://rsshub:1200/openai/news" + assert source["enabled"] == 1 + assert connection.execute("SELECT COUNT(*) FROM items").fetchone()[0] == 1 + assert connection.execute("SELECT COUNT(*) FROM acked_items").fetchone()[0] == 1 + poll_state = connection.execute( + "SELECT last_polled_at, last_success_at, last_error FROM poll_state WHERE source_id = ?", + (source_id,), + ).fetchone() + assert poll_state is not None + assert tuple(poll_state) == (None, None, None) + finally: + connection.close() + + +def test_feed_manage_mutations_require_one_exact_name( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AKA_PLUGIN_DATA_DIR", str(tmp_path)) + assert "已订阅" in feed_backend.feed_manage( + action="subscribe", name="OpenAI Blog", url="https://example.com/openai.xml" + ) + assert "没有找到" in feed_backend.feed_manage(action="pause", name="OpenAI") + assert "update 需要 url" in feed_backend.feed_manage(action="update", name="OpenAI Blog") + + def test_concurrent_legacy_connections_share_one_schema_migration( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/plugin.py b/plugin.py index fd5eda5..f66aeaa 100644 --- a/plugin.py +++ b/plugin.py @@ -23,7 +23,7 @@ class FeedConfig(BaseModel): api_version = 3 name = "feed" -version = "3.1.3" +version = "3.1.4" desc = "由 Timer 驱动的 Feed Content source 与用户 MCP" Config = FeedConfig inject = (MCP_SERVERS, TIMERS) diff --git a/skills/feed-manage/SKILL.md b/skills/feed-manage/SKILL.md index 29c1fed..62edb03 100644 --- a/skills/feed-manage/SKILL.md +++ b/skills/feed-manage/SKILL.md @@ -62,6 +62,19 @@ mcp_feed__feed_manage(action="subscribe", name="名称", url="RSS地址") mcp_feed__feed_manage(action="unsubscribe", name="名称") ``` +取消订阅会删除该来源的历史内容。只想停止轮询时,使用暂停: + +``` +mcp_feed__feed_manage(action="pause", name="完整名称") +mcp_feed__feed_manage(action="resume", name="完整名称") +``` + +更新地址会保留历史内容,并清空旧地址的轮询状态: + +``` +mcp_feed__feed_manage(action="update", name="完整名称", url="新 RSS 地址") +``` + ## 注意 - 问"你有什么信息来源"时,先调 `mcp_feed__feed_manage(action=list)` 拿完整列表,再根据列表内容回答,**不得凭记忆或推测列举** diff --git a/tests/test_plugin.py b/tests/test_plugin.py index fcfff01..fe2fd6a 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -51,7 +51,7 @@ def bind(self, source_id: str) -> BoundContentSource: def test_pure_v3_exports_and_exact_apply() -> None: assert plugin.api_version == 3 assert plugin.name == "feed" - assert plugin.version == "3.1.3" + assert plugin.version == "3.1.4" assert plugin.skill_roots == ("skills",) assert tuple(inspect.signature(plugin.apply).parameters) == ("ctx", "config") assert ComposablePlugin.from_module(plugin).skill_roots == ("skills",) From fd74018c2a397fcc1e6bfc2c6f5726cc0ba8e098 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 29 Aug 2026 02:35:10 +0800 Subject: [PATCH 2/2] fix: align feed manifest version --- akashic.plugin.toml | 2 +- tests/test_plugin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/akashic.plugin.toml b/akashic.plugin.toml index 80e1735..487835d 100644 --- a/akashic.plugin.toml +++ b/akashic.plugin.toml @@ -1,6 +1,6 @@ schema_version = 1 name = "feed" -version = "3.1.3" +version = "3.1.4" api_version = 3 entrypoint = "plugin.py" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index fe2fd6a..b4c4ea3 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -126,7 +126,7 @@ def test_static_manifest_freezes_tools_and_data_exclusions() -> None: manifest = load_static_plugin_manifest(ROOT) assert manifest.name == "feed" - assert manifest.version == "3.1.3" + assert manifest.version == plugin.version == "3.1.4" assert manifest.api_version == 3 assert manifest.requirements == ("mcp/requirements.txt",) assert "feed_mcp.sqlite3" in manifest.exclude_data_paths