Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion akashic.plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema_version = 1
name = "feed"
version = "3.1.3"
version = "3.1.4"
api_version = 3
entrypoint = "plugin.py"

Expand Down
57 changes: 56 additions & 1 deletion feed_runtime/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion mcp/src/mcp_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def feed_manage(
source_type: str = "rss",
note: str = "",
) -> str:
"""管理 RSS 订阅源:添加、删除、列出订阅。"""
"""管理 RSS 订阅源:添加、删除、列出、暂停、恢复或更新地址。"""

return _live_backend().feed_manage(
action=action,
Expand Down
88 changes: 87 additions & 1 deletion mcp/tests/test_runtime_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions skills/feed-manage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` 拿完整列表,再根据列表内容回答,**不得凭记忆或推测列举**
Expand Down
4 changes: 2 additions & 2 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)
Expand Down Expand Up @@ -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
Expand Down
Loading