@lemoncat7/dsh-partner 2.23.55 → 2.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/README.md +7 -2
  2. package/docs/compatibility-0.2.2-rc.1.md +7 -0
  3. package/docs/releases/2.24.0-rc.10.md +8 -0
  4. package/docs/releases/2.24.0-rc.11.md +10 -0
  5. package/docs/releases/2.24.0-rc.2.md +9 -0
  6. package/docs/releases/2.24.0-rc.3.md +6 -0
  7. package/docs/releases/2.24.0-rc.4.md +11 -0
  8. package/docs/releases/2.24.0-rc.5.md +11 -0
  9. package/docs/releases/2.24.0-rc.6.md +12 -0
  10. package/docs/releases/2.24.0-rc.9.md +5 -0
  11. package/docs/releases/2.25.0-rc.1.md +12 -0
  12. package/docs/releases/2.25.0-rc.11.md +5 -0
  13. package/docs/releases/2.25.0-rc.2.md +10 -0
  14. package/docs/releases/2.25.0-rc.3.md +11 -0
  15. package/docs/releases/2.25.0-rc.4.md +3 -0
  16. package/docs/releases/2.25.0-rc.5.md +3 -0
  17. package/docs/releases/2.25.0.md +32 -0
  18. package/lib/agent-runtime.d.ts +3 -1
  19. package/lib/agent-runtime.d.ts.map +1 -1
  20. package/lib/agent-runtime.js +91 -18
  21. package/lib/agent-runtime.js.map +1 -1
  22. package/lib/api/features/tts-api.d.ts +4 -0
  23. package/lib/api/features/tts-api.d.ts.map +1 -0
  24. package/lib/api/features/tts-api.js +49 -0
  25. package/lib/api/features/tts-api.js.map +1 -0
  26. package/lib/api.d.ts +3 -0
  27. package/lib/api.d.ts.map +1 -1
  28. package/lib/api.js +79 -6
  29. package/lib/api.js.map +1 -1
  30. package/lib/channels/activity-defaults.d.ts.map +1 -1
  31. package/lib/channels/activity-defaults.js +7 -0
  32. package/lib/channels/activity-defaults.js.map +1 -1
  33. package/lib/channels/bilibili/activity-provider.d.ts +3 -0
  34. package/lib/channels/bilibili/activity-provider.d.ts.map +1 -0
  35. package/lib/channels/bilibili/activity-provider.js +8 -0
  36. package/lib/channels/bilibili/activity-provider.js.map +1 -0
  37. package/lib/channels/bilibili/catalog.d.ts +23 -0
  38. package/lib/channels/bilibili/catalog.d.ts.map +1 -0
  39. package/lib/channels/bilibili/catalog.js +45 -0
  40. package/lib/channels/bilibili/catalog.js.map +1 -0
  41. package/lib/channels/bilibili/media.d.ts +9 -0
  42. package/lib/channels/bilibili/media.d.ts.map +1 -0
  43. package/lib/channels/bilibili/media.js +67 -0
  44. package/lib/channels/bilibili/media.js.map +1 -0
  45. package/lib/channels/bilibili/service.d.ts +37 -0
  46. package/lib/channels/bilibili/service.d.ts.map +1 -0
  47. package/lib/channels/bilibili/service.js +138 -0
  48. package/lib/channels/bilibili/service.js.map +1 -0
  49. package/lib/channels/bilibili/tools.d.ts +6 -0
  50. package/lib/channels/bilibili/tools.d.ts.map +1 -0
  51. package/lib/channels/bilibili/tools.js +48 -0
  52. package/lib/channels/bilibili/tools.js.map +1 -0
  53. package/lib/channels/direct/message-format.js +298 -206
  54. package/lib/channels/direct/message-format.js.map +4 -4
  55. package/lib/channels/direct/transport.d.ts +1 -1
  56. package/lib/channels/direct/transport.d.ts.map +1 -1
  57. package/lib/channels/manager.d.ts +2 -0
  58. package/lib/channels/manager.d.ts.map +1 -1
  59. package/lib/channels/manager.js +24 -4
  60. package/lib/channels/manager.js.map +1 -1
  61. package/lib/channels/outbound-media.d.ts +1 -1
  62. package/lib/channels/outbound-media.d.ts.map +1 -1
  63. package/lib/channels/outbound-media.js +3 -2
  64. package/lib/channels/outbound-media.js.map +1 -1
  65. package/lib/channels/qq/authority.d.ts +1 -1
  66. package/lib/channels/qq/authority.d.ts.map +1 -1
  67. package/lib/channels/qq/authority.js +2 -2
  68. package/lib/channels/qq/authority.js.map +1 -1
  69. package/lib/channels/qq/reply-tool.d.ts +1 -1
  70. package/lib/channels/qq/reply-tool.d.ts.map +1 -1
  71. package/lib/channels/qq/reply-tool.js +7 -2
  72. package/lib/channels/qq/reply-tool.js.map +1 -1
  73. package/lib/channels/weixin/api.d.ts +1 -1
  74. package/lib/channels/weixin/api.d.ts.map +1 -1
  75. package/lib/channels/weixin/api.js +7 -1
  76. package/lib/channels/weixin/api.js.map +1 -1
  77. package/lib/channels/weixin/connect.d.ts +5 -1
  78. package/lib/channels/weixin/connect.d.ts.map +1 -1
  79. package/lib/character/activity-journal.d.ts +3 -1
  80. package/lib/character/activity-journal.d.ts.map +1 -1
  81. package/lib/character/activity-journal.js +43 -7
  82. package/lib/character/activity-journal.js.map +1 -1
  83. package/lib/character/activity-summary.d.ts +23 -0
  84. package/lib/character/activity-summary.d.ts.map +1 -0
  85. package/lib/character/activity-summary.js +23 -0
  86. package/lib/character/activity-summary.js.map +1 -0
  87. package/lib/character/affection-tool.js +2 -2
  88. package/lib/character/affection-tool.js.map +1 -1
  89. package/lib/character/affection.d.ts +6 -42
  90. package/lib/character/affection.d.ts.map +1 -1
  91. package/lib/character/affection.js +4 -13
  92. package/lib/character/affection.js.map +1 -1
  93. package/lib/character/conversation-behavior.d.ts +1 -0
  94. package/lib/character/conversation-behavior.d.ts.map +1 -1
  95. package/lib/character/conversation-behavior.js +8 -5
  96. package/lib/character/conversation-behavior.js.map +1 -1
  97. package/lib/character/habit-tool.d.ts +4 -0
  98. package/lib/character/habit-tool.d.ts.map +1 -0
  99. package/lib/character/habit-tool.js +31 -0
  100. package/lib/character/habit-tool.js.map +1 -0
  101. package/lib/character/habits.d.ts +38 -0
  102. package/lib/character/habits.d.ts.map +1 -0
  103. package/lib/character/habits.js +92 -0
  104. package/lib/character/habits.js.map +1 -0
  105. package/lib/character/idle-contact.d.ts +9 -0
  106. package/lib/character/idle-contact.d.ts.map +1 -1
  107. package/lib/character/idle-contact.js +62 -9
  108. package/lib/character/idle-contact.js.map +1 -1
  109. package/lib/character/interaction-review.d.ts +10 -0
  110. package/lib/character/interaction-review.d.ts.map +1 -1
  111. package/lib/character/interaction-review.js +51 -31
  112. package/lib/character/interaction-review.js.map +1 -1
  113. package/lib/chat/live-speech.d.ts +5 -2
  114. package/lib/chat/live-speech.d.ts.map +1 -1
  115. package/lib/chat/live-speech.js +37 -9
  116. package/lib/chat/live-speech.js.map +1 -1
  117. package/lib/chat/persona-delivery.d.ts +31 -0
  118. package/lib/chat/persona-delivery.d.ts.map +1 -0
  119. package/lib/chat/persona-delivery.js +131 -0
  120. package/lib/chat/persona-delivery.js.map +1 -0
  121. package/lib/chat/protocol.d.ts +1 -1
  122. package/lib/chat/protocol.js +1 -1
  123. package/lib/chat/reply-continuity.d.ts.map +1 -1
  124. package/lib/chat/reply-continuity.js +12 -0
  125. package/lib/chat/reply-continuity.js.map +1 -1
  126. package/lib/chat/tools.d.ts.map +1 -1
  127. package/lib/chat/tools.js +26 -11
  128. package/lib/chat/tools.js.map +1 -1
  129. package/lib/chat/work-expression.d.ts +36 -0
  130. package/lib/chat/work-expression.d.ts.map +1 -1
  131. package/lib/chat/work-expression.js +80 -17
  132. package/lib/chat/work-expression.js.map +1 -1
  133. package/lib/client-api.d.ts +17 -1
  134. package/lib/client-api.d.ts.map +1 -1
  135. package/lib/client-api.js.map +1 -1
  136. package/lib/client.activities.js +3 -0
  137. package/lib/client.d.ts.map +1 -1
  138. package/lib/client.js +4119 -3763
  139. package/lib/client.js.map +4 -4
  140. package/lib/client.tts.js +7 -0
  141. package/lib/collaboration/composition.d.ts.map +1 -1
  142. package/lib/collaboration/composition.js +2 -0
  143. package/lib/collaboration/composition.js.map +1 -1
  144. package/lib/collaboration/tool-discovery.d.ts +1 -1
  145. package/lib/collaboration/tool-discovery.d.ts.map +1 -1
  146. package/lib/collaboration/tool-discovery.js +15 -9
  147. package/lib/collaboration/tool-discovery.js.map +1 -1
  148. package/lib/companions/identity-tool.d.ts +1 -1
  149. package/lib/companions/identity-tool.d.ts.map +1 -1
  150. package/lib/companions/identity-tool.js +3 -4
  151. package/lib/companions/identity-tool.js.map +1 -1
  152. package/lib/companions/identity.js +4 -4
  153. package/lib/companions/identity.js.map +1 -1
  154. package/lib/companions/management.d.ts +4 -4
  155. package/lib/companions/persona-refinement.d.ts.map +1 -1
  156. package/lib/companions/persona-refinement.js +1 -45
  157. package/lib/companions/persona-refinement.js.map +1 -1
  158. package/lib/compat/native-presentation.d.ts +4 -0
  159. package/lib/compat/native-presentation.d.ts.map +1 -0
  160. package/lib/compat/native-presentation.js +40 -0
  161. package/lib/compat/native-presentation.js.map +1 -0
  162. package/lib/compat/ptc-events.d.ts +2 -0
  163. package/lib/compat/ptc-events.d.ts.map +1 -0
  164. package/lib/compat/ptc-events.js +2 -0
  165. package/lib/compat/ptc-events.js.map +1 -0
  166. package/lib/compat/tool-dispatch.d.ts +4 -0
  167. package/lib/compat/tool-dispatch.d.ts.map +1 -0
  168. package/lib/compat/tool-dispatch.js +14 -0
  169. package/lib/compat/tool-dispatch.js.map +1 -0
  170. package/lib/concern-execution-receipt.d.ts +2 -1
  171. package/lib/concern-execution-receipt.d.ts.map +1 -1
  172. package/lib/concern-execution-receipt.js +15 -13
  173. package/lib/concern-execution-receipt.js.map +1 -1
  174. package/lib/concern-execution.d.ts +3 -1
  175. package/lib/concern-execution.d.ts.map +1 -1
  176. package/lib/concern-execution.js +20 -1
  177. package/lib/concern-execution.js.map +1 -1
  178. package/lib/concern-recording.d.ts +1 -1
  179. package/lib/concern-recording.d.ts.map +1 -1
  180. package/lib/concern-recording.js +14 -5
  181. package/lib/concern-recording.js.map +1 -1
  182. package/lib/concern-store.d.ts.map +1 -1
  183. package/lib/concern-store.js +3 -1
  184. package/lib/concern-store.js.map +1 -1
  185. package/lib/concern-tool.d.ts.map +1 -1
  186. package/lib/concern-tool.js +5 -3
  187. package/lib/concern-tool.js.map +1 -1
  188. package/lib/domain.d.ts +13 -1
  189. package/lib/domain.d.ts.map +1 -1
  190. package/lib/domain.js.map +1 -1
  191. package/lib/execution/operation-tool-access.d.ts.map +1 -1
  192. package/lib/execution/operation-tool-access.js +2 -6
  193. package/lib/execution/operation-tool-access.js.map +1 -1
  194. package/lib/execution/service.d.ts +2 -0
  195. package/lib/execution/service.d.ts.map +1 -1
  196. package/lib/execution/service.js +7 -1
  197. package/lib/execution/service.js.map +1 -1
  198. package/lib/execution/task-preamble.js +2 -2
  199. package/lib/execution/task-preamble.js.map +1 -1
  200. package/lib/execution/work-progress-card.d.ts +10 -0
  201. package/lib/execution/work-progress-card.d.ts.map +1 -0
  202. package/lib/execution/work-progress-card.js +45 -0
  203. package/lib/execution/work-progress-card.js.map +1 -0
  204. package/lib/execution/work-progress-state.d.ts +32 -0
  205. package/lib/execution/work-progress-state.d.ts.map +1 -0
  206. package/lib/execution/work-progress-state.js +19 -0
  207. package/lib/execution/work-progress-state.js.map +1 -0
  208. package/lib/execution/work-progress.d.ts +16 -0
  209. package/lib/execution/work-progress.d.ts.map +1 -0
  210. package/lib/execution/work-progress.js +76 -0
  211. package/lib/execution/work-progress.js.map +1 -0
  212. package/lib/execution/work-tool.d.ts.map +1 -1
  213. package/lib/execution/work-tool.js +63 -52
  214. package/lib/execution/work-tool.js.map +1 -1
  215. package/lib/heartbeat.js +1 -1
  216. package/lib/heartbeat.js.map +1 -1
  217. package/lib/index.d.ts.map +1 -1
  218. package/lib/index.js +11 -3
  219. package/lib/index.js.map +1 -1
  220. package/lib/memory-store.d.ts +1 -1
  221. package/lib/observation-loop.d.ts.map +1 -1
  222. package/lib/observation-loop.js +24 -6
  223. package/lib/observation-loop.js.map +1 -1
  224. package/lib/store.d.ts.map +1 -1
  225. package/lib/store.js +12 -1
  226. package/lib/store.js.map +1 -1
  227. package/lib/ui/affection-panel.js +1 -1
  228. package/lib/ui/affection-panel.js.map +1 -1
  229. package/lib/ui/bilibili-settings.d.ts +12 -0
  230. package/lib/ui/bilibili-settings.d.ts.map +1 -0
  231. package/lib/ui/bilibili-settings.js +77 -0
  232. package/lib/ui/bilibili-settings.js.map +1 -0
  233. package/lib/ui/channels-panel.d.ts.map +1 -1
  234. package/lib/ui/channels-panel.js +11 -4
  235. package/lib/ui/channels-panel.js.map +1 -1
  236. package/lib/ui/companion-vitals.js +1 -1
  237. package/lib/ui/companion-vitals.js.map +1 -1
  238. package/lib/ui/habits-panel.d.ts +4 -0
  239. package/lib/ui/habits-panel.d.ts.map +1 -0
  240. package/lib/ui/habits-panel.js +30 -0
  241. package/lib/ui/habits-panel.js.map +1 -0
  242. package/lib/ui/identity-editor.js +1 -8
  243. package/lib/ui/identity-editor.js.map +1 -1
  244. package/lib/ui/idle-contact-editor.d.ts.map +1 -1
  245. package/lib/ui/idle-contact-editor.js +3 -2
  246. package/lib/ui/idle-contact-editor.js.map +1 -1
  247. package/lib/ui/memory/concern-board.js +1 -1
  248. package/lib/ui/memory/concern-board.js.map +1 -1
  249. package/lib/ui/memory/concern-execution-status.d.ts.map +1 -1
  250. package/lib/ui/memory/concern-execution-status.js +2 -2
  251. package/lib/ui/memory/concern-execution-status.js.map +1 -1
  252. package/lib/ui/push-draft.d.ts +2 -2
  253. package/lib/ui/qq-channel-settings.js +3 -3
  254. package/lib/ui/qq-channel-settings.js.map +1 -1
  255. package/lib/ui/thoughts-panel-loader.d.ts +4 -0
  256. package/lib/ui/thoughts-panel-loader.d.ts.map +1 -0
  257. package/lib/ui/thoughts-panel-loader.js +23 -0
  258. package/lib/ui/thoughts-panel-loader.js.map +1 -0
  259. package/lib/ui/thoughts-panel.d.ts.map +1 -1
  260. package/lib/ui/thoughts-panel.js +7 -5
  261. package/lib/ui/thoughts-panel.js.map +1 -1
  262. package/lib/ui/tts-settings-loader.d.ts +4 -0
  263. package/lib/ui/tts-settings-loader.d.ts.map +1 -0
  264. package/lib/ui/tts-settings-loader.js +23 -0
  265. package/lib/ui/tts-settings-loader.js.map +1 -0
  266. package/lib/ui/tts-settings.d.ts +11 -0
  267. package/lib/ui/tts-settings.d.ts.map +1 -0
  268. package/lib/ui/tts-settings.js +63 -0
  269. package/lib/ui/tts-settings.js.map +1 -0
  270. package/lib/ui/workspace-navigation.d.ts +16 -16
  271. package/lib/voice/credentials.d.ts +9 -0
  272. package/lib/voice/credentials.d.ts.map +1 -0
  273. package/lib/voice/credentials.js +12 -0
  274. package/lib/voice/credentials.js.map +1 -0
  275. package/lib/voice/domain.d.ts +42 -0
  276. package/lib/voice/domain.d.ts.map +1 -0
  277. package/lib/voice/domain.js +53 -0
  278. package/lib/voice/domain.js.map +1 -0
  279. package/lib/voice/service.d.ts +63 -0
  280. package/lib/voice/service.d.ts.map +1 -0
  281. package/lib/voice/service.js +211 -0
  282. package/lib/voice/service.js.map +1 -0
  283. package/lib/voice/synthesize.d.ts +12 -0
  284. package/lib/voice/synthesize.d.ts.map +1 -0
  285. package/lib/voice/synthesize.js +97 -0
  286. package/lib/voice/synthesize.js.map +1 -0
  287. package/lib/voice/tool.d.ts +7 -0
  288. package/lib/voice/tool.d.ts.map +1 -0
  289. package/lib/voice/tool.js +73 -0
  290. package/lib/voice/tool.js.map +1 -0
  291. package/lib/voice/validation.d.ts +3 -0
  292. package/lib/voice/validation.d.ts.map +1 -0
  293. package/lib/voice/validation.js +31 -0
  294. package/lib/voice/validation.js.map +1 -0
  295. package/package.json +71 -48
  296. package/resources/bilibili/Dockerfile +7 -0
  297. package/resources/bilibili/media.py +245 -0
  298. package/resources/bilibili/requirements.txt +3 -0
  299. package/resources/bilibili/server.py +300 -0
  300. package/resources/bilibili/test_bridge.py +115 -0
  301. package/resources/bilibili/test_media.py +110 -0
@@ -0,0 +1,300 @@
1
+ """Private Bilibili bridge. No MCP dependency, arbitrary URL fetch or credential export."""
2
+ import asyncio, base64, hashlib, hmac, json, os, re, sqlite3, time
3
+ from pathlib import Path
4
+ from aiohttp import web, ClientSession, ClientTimeout
5
+ from bilibili_api import video, search, comment, Credential, dynamic, hot, user, session, favorite_list
6
+ from bilibili_api.login_v2 import QrCodeLoginEvents
7
+ from io import BytesIO
8
+ from urllib.parse import urlparse, parse_qs
9
+ import qrcode
10
+
11
+ ROOT = Path(os.environ.get('BILI_DATA', '/state'))
12
+ ROOT.mkdir(parents=True, exist_ok=True)
13
+ TOKEN = Path(os.environ.get('BILI_TOKEN_FILE', '/run/bridge-token')).read_text().strip()
14
+ PUBLIC = {'search','hot','video','comments','subtitle','user_videos','user_dynamics','share_link','video_stream'}
15
+ READ = PUBLIC | {'favorite_lists','favorite_content','replies','mentions','unread','upload_status','upload_categories','upload_health'}
16
+ WRITE = {'like','favorite','follow','comment','dynamic','share_dynamic','repost','upload_draft','publish_video','image_dynamic'}
17
+ ACCOUNT = {'status','login','login_check','logout'}
18
+ LOGINS = {}
19
+ LOGIN_TASKS = {}
20
+ LOGIN_RESULTS = {}
21
+ LOCKS = {}
22
+ LAST = {}
23
+ DB = sqlite3.connect(ROOT / 'receipts.sqlite')
24
+ DB.execute('CREATE TABLE IF NOT EXISTS receipts(channel TEXT, id TEXT, at REAL, status TEXT, result TEXT, PRIMARY KEY(channel,id))')
25
+ DB.commit()
26
+ os.chmod(ROOT / 'receipts.sqlite', 0o600)
27
+
28
+ def credential(channel):
29
+ path = ROOT / (channel + '.json')
30
+ return Credential(**json.loads(path.read_text())) if path.exists() else Credential()
31
+
32
+ def save_credential(channel, cred):
33
+ fields = ['sessdata','bili_jct','buvid3','dedeuserid','ac_time_value']
34
+ data = {k:getattr(cred,k,None) for k in fields if getattr(cred,k,None)}
35
+ path = ROOT / (channel + '.json'); tmp = path.with_suffix('.tmp')
36
+ fd = os.open(tmp, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
37
+ with os.fdopen(fd, 'w') as f:
38
+ json.dump(data,f); f.flush(); os.fsync(f.fileno())
39
+ os.replace(tmp,path)
40
+
41
+ def bounded(value, depth=0):
42
+ if depth > 8: return None
43
+ if isinstance(value,int) and abs(value)>9007199254740991:return str(value)
44
+ if isinstance(value,str):return value[:6000]
45
+ if isinstance(value,list):return [bounded(v,depth+1) for v in value[:30]]
46
+ if isinstance(value,dict):return {k:bounded(v,depth+1) for k,v in list(value.items())[:50] if not re.search(r'cookie|token|sessdata|bili_jct|buvid|password|secret',k,re.I)}
47
+ return value
48
+
49
+ def videos(items):
50
+ return [{k:x.get(k) for k in ['bvid','aid','title','author','owner','description','desc','duration','stat']} for x in items[:20]]
51
+
52
+ def login_credential(data, cookies):
53
+ # Current web login issues credentials in Set-Cookie, not necessarily the redirect URL.
54
+ query = parse_qs(urlparse(data.get('url','')).query)
55
+ values = {k.lower():v[-1] for k,v in query.items() if v}
56
+ values.update({k.lower():v.value for k,v in cookies.items()})
57
+ return Credential(sessdata=values.get('sessdata'),bili_jct=values.get('bili_jct'),
58
+ dedeuserid=values.get('dedeuserid'),ac_time_value=data.get('refresh_token'))
59
+
60
+ class QrCodeLogin:
61
+ async def generate_qrcode(self):
62
+ async with ClientSession(timeout=ClientTimeout(total=15), headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36','Referer':'https://www.bilibili.com/'}) as client:
63
+ async with client.get('https://passport.bilibili.com/x/passport-login/web/qrcode/generate',params={'source':'main-fe-header'}) as response:
64
+ response.raise_for_status(); payload=await response.json()
65
+ if payload.get('code') != 0: raise ValueError('B 站暂时无法生成二维码')
66
+ data=payload['data']; self.key=data['qrcode_key']
67
+ image=BytesIO(); qrcode.make(data['url']).save(image,format='PNG'); self.image=image.getvalue()
68
+ def get_qrcode_picture(self):
69
+ return type('Picture',(),{'content':self.image})()
70
+ async def check_state(self):
71
+ async with ClientSession(timeout=ClientTimeout(total=15), headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36','Referer':'https://www.bilibili.com/'}) as client:
72
+ async with client.get('https://passport.bilibili.com/x/passport-login/web/qrcode/poll',params={'qrcode_key':self.key,'source':'main-fe-header'},allow_redirects=False) as response:
73
+ response.raise_for_status(); payload=await response.json(); cookies=response.cookies
74
+ if payload.get('code') != 0: raise ValueError('B 站扫码状态查询失败')
75
+ data=payload['data']; code=data.get('code')
76
+ if code == 86101: return QrCodeLoginEvents.SCAN
77
+ if code == 86090: return QrCodeLoginEvents.CONF
78
+ if code == 86038: return QrCodeLoginEvents.TIMEOUT
79
+ if code != 0: raise ValueError('B 站扫码尚未完成')
80
+ self.cred=login_credential(data,cookies)
81
+ if not self.cred.sessdata or not self.cred.bili_jct:
82
+ raise ValueError('扫码响应缺少登录凭证,请重新扫码')
83
+ return QrCodeLoginEvents.DONE
84
+ def get_credential(self):
85
+ return self.cred
86
+
87
+ async def identity(cred):
88
+ result = {'logged_in':True,'uid':str(cred.dedeuserid)}
89
+ try:
90
+ info = await asyncio.wait_for(user.get_self_info(credential=cred),8)
91
+ result['name'] = str(info.get('name',''))[:100]
92
+ face = str(info.get('face',''))
93
+ from urllib.parse import urlparse
94
+ if urlparse(face).scheme == 'https' and (urlparse(face).hostname or '').endswith('.hdslb.com'):
95
+ result['avatar'] = face
96
+ except Exception:
97
+ pass # Profile failure must not erase a valid login.
98
+ return result
99
+
100
+ async def monitor_login(channel, qr):
101
+ try:
102
+ for _ in range(60):
103
+ await asyncio.sleep(3)
104
+ state = await qr.check_state()
105
+ if state == QrCodeLoginEvents.DONE:
106
+ cred = qr.get_credential()
107
+ if not cred.sessdata or not cred.bili_jct or not await cred.check_valid():
108
+ raise ValueError('扫码凭证未通过登录验证')
109
+ if not cred.dedeuserid:
110
+ info = await user.get_self_info(credential=cred)
111
+ cred.dedeuserid = str(info['mid'])
112
+ save_credential(channel,cred)
113
+ LOGIN_RESULTS[channel] = {'status':'done', **await identity(cred)}
114
+ return
115
+ if state == QrCodeLoginEvents.TIMEOUT:
116
+ break
117
+ LOGIN_RESULTS[channel] = {'status':'scanned' if state == QrCodeLoginEvents.CONF else 'waiting'}
118
+ LOGIN_RESULTS[channel] = {'status':'expired'}
119
+ except asyncio.CancelledError:
120
+ raise
121
+ except Exception:
122
+ LOGIN_RESULTS[channel] = {'status':'error'}
123
+ finally:
124
+ if LOGINS.get(channel, (None,None))[1] is qr:
125
+ LOGINS.pop(channel,None)
126
+
127
+ async def account(channel, op):
128
+ cred = credential(channel)
129
+ if op == 'logout':
130
+ await MEDIA.cancel(channel)
131
+ task=LOGIN_TASKS.pop(channel,None)
132
+ if task:
133
+ task.cancel()
134
+ await asyncio.gather(task,return_exceptions=True)
135
+ (ROOT/(channel+'.json')).unlink(missing_ok=True); LOGINS.pop(channel,None); LOGIN_RESULTS.pop(channel,None)
136
+ return {'logged_in':False}
137
+ if op == 'status':
138
+ valid = bool(cred.sessdata) and await cred.check_valid()
139
+ return await identity(cred) if valid else {'logged_in':False,'uid':None}
140
+ if op == 'login':
141
+ old=LOGIN_TASKS.pop(channel,None)
142
+ if old:
143
+ old.cancel()
144
+ await asyncio.gather(old,return_exceptions=True)
145
+ qr=QrCodeLogin(); await qr.generate_qrcode(); LOGINS[channel]=(time.time(),qr)
146
+ LOGIN_RESULTS[channel]={'status':'waiting'}
147
+ LOGIN_TASKS[channel]=asyncio.create_task(monitor_login(channel,qr))
148
+ return {'status':'qrcode','image':'data:image/png;base64,'+base64.b64encode(qr.get_qrcode_picture().content).decode(),'expiresIn':180}
149
+ return LOGIN_RESULTS.get(channel,{'status':'expired'})
150
+
151
+ from media import Media, media_url, HEADERS
152
+ MEDIA = Media(ROOT, credential)
153
+
154
+ async def execute(channel, op, p):
155
+ cred=credential(channel)
156
+ if op not in PUBLIC and (not cred.sessdata or not await cred.check_valid()):
157
+ raise ValueError('B 站登录已失效,请重新扫码;公开浏览仍可使用')
158
+ if op=='video_stream':
159
+ v=video.Video(bvid=p['bvid'],credential=cred)
160
+ info=await v.get_download_url(page_index=p.get('page',1)-1,html5=True)
161
+ def link(stream):
162
+ urls=[stream.get('baseUrl') or stream.get('base_url') or stream.get('url'),*(stream.get('backupUrl') or stream.get('backup_url') or [])]
163
+ for url in urls:
164
+ if not url:continue
165
+ try:return media_url(url)
166
+ except ValueError:pass
167
+ raise ValueError('暂未获得可用的视频播放直链')
168
+ parts=info.get('durl') or []
169
+ result={'bvid':p['bvid'],'page':p.get('page',1),'headers':HEADERS,'downloaded':False,'interpreted':False,'temporary':True,'message':'仅返回临时播放链接,未下载、合并或分析。模型自行决定是否读取与处理;链接失效请重新获取。'}
170
+ if parts:
171
+ result.update(mode='progressive',segments=[{'url':link(x),'durationMs':x.get('length')} for x in parts])
172
+ else:
173
+ dash=info.get('dash') or {}
174
+ if not dash.get('video'):raise ValueError('该视频暂无可用播放流')
175
+ stream=min(dash['video'],key=lambda x:(not str(x.get('codecs','')).startswith('avc'),x.get('bandwidth',10**9)))
176
+ result.update(mode='dash',video={'url':link(stream),'codec':stream.get('codecs'),'width':stream.get('width'),'height':stream.get('height')})
177
+ if dash.get('audio'):
178
+ audio=min(dash['audio'],key=lambda x:x.get('bandwidth',10**9));result['audio']={'url':link(audio),'codec':audio.get('codecs')}
179
+ return result
180
+ if op in ('upload_draft','image_dynamic'):return await MEDIA.start(channel,op,p)
181
+ if op=='upload_health':return {'cooldown':MEDIA.cooldown(channel),'instruction':'有冷却记录时不要重试。无记录仅表示本地未拦截,不代表平台放行。'}
182
+ if op=='upload_status':
183
+ _,job=MEDIA.load(channel,p['job_id'])
184
+ return MEDIA.public(job)
185
+ if op=='publish_video':return await MEDIA.publish(channel,p)
186
+ if op=='upload_categories':
187
+ from bilibili_api.utils.network import Api
188
+ r=await Api(url='https://member.bilibili.com/x/vupre/web/archive/pre',method='GET',verify=True,credential=cred).update_params(lang='cn').result
189
+ return {'types':r.get('typelist',r.get('types',[]))}
190
+ page=p.get('page',1)
191
+ if op=='search':
192
+ r=await search.search_by_type(keyword=p['keyword'],search_type=search.SearchObjectType.VIDEO,page=page)
193
+ return {'videos':videos(r.get('result',[]))}
194
+ if op=='hot':return {'videos':videos((await hot.get_hot_videos(pn=page,ps=20)).get('list',[]))}
195
+ if op in {'user_videos','user_dynamics','follow'}:
196
+ u=user.User(int(p['uid']),credential=cred)
197
+ if op=='user_videos':return await u.get_videos(pn=page,ps=20)
198
+ if op=='user_dynamics':return await u.get_dynamics_new(offset=p.get('offset',''))
199
+ return await u.modify_relation(user.RelationType.SUBSCRIBE if p['follow'] else user.RelationType.UNSUBSCRIBE)
200
+ if op=='favorite_lists':return await favorite_list.get_video_favorite_list(int(cred.dedeuserid),credential=cred)
201
+ if op=='favorite_content':return await favorite_list.FavoriteList(media_id=int(p['media_id']),credential=cred).get_content(page=page)
202
+ if op=='replies':return await session.get_replies(credential=cred)
203
+ if op=='mentions':return await session.get_at(credential=cred)
204
+ if op=='unread':return await session.get_unread_messages(credential=cred)
205
+ if op=='repost':return await dynamic.Dynamic(int(p['dynamic_id']),credential=cred).repost(p['text'])
206
+ if op in {'dynamic','share_dynamic'}:
207
+ content=p['text']
208
+ if op=='share_dynamic':content+='\nhttps://www.bilibili.com/video/'+p['bvid']
209
+ return await dynamic.send_dynamic(info=dynamic.BuildDynamic.empty().add_plain_text(content),credential=cred)
210
+ v=video.Video(bvid=p['bvid'],credential=cred)
211
+ if op=='share_link':return {'url':'https://www.bilibili.com/video/'+p['bvid'],'published':False,'instruction':'仅生成链接,尚未发送给任何人;使用已授权聊天渠道分享。'}
212
+ if op=='video':return await v.get_info()
213
+ if op=='like':return await v.like(p['status'])
214
+ if op=='favorite':return await v.set_favorite(add_media_ids=[int(p['media_id'])] if p['add'] else [],del_media_ids=[] if p['add'] else [int(p['media_id'])])
215
+ if op=='comments':return await comment.get_comments(oid=v.get_aid(),type_=comment.CommentResourceType.VIDEO,page_index=page,credential=cred)
216
+ if op=='comment':return await comment.send_comment(text=p['text'],oid=v.get_aid(),type_=comment.CommentResourceType.VIDEO,root=int(p['root']) if p.get('root') else None,parent=int(p['parent']) if p.get('parent') else None,credential=cred)
217
+ if op=='subtitle':
218
+ cid=await v.get_cid(page_index=0)
219
+ subtitles=(await v.get_subtitle(cid=cid) or {}).get('subtitles',[])
220
+ if not subtitles:return {'available':False,'message':'没有可用字幕,不能据此声称看过视频。'}
221
+ item=next((s for s in subtitles if s.get('lan') in ['ai-zh','zh-CN','zh']),subtitles[0])
222
+ url=item['subtitle_url'];url='https:'+url if url.startswith('//') else url
223
+ from urllib.parse import urlparse
224
+ parsed=urlparse(url)
225
+ if parsed.scheme!='https' or not (parsed.hostname or '').endswith('.hdslb.com'):raise ValueError('字幕来源不受支持')
226
+ async with ClientSession(timeout=ClientTimeout(total=15), headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36','Referer':'https://www.bilibili.com/'}) as client:
227
+ async with client.get(url,allow_redirects=False) as response:
228
+ if response.status!=200:raise ValueError('字幕暂不可用')
229
+ chunks=[];size=0
230
+ async for chunk in response.content.iter_chunked(65536):
231
+ size+=len(chunk)
232
+ if size>1024*1024:raise ValueError('字幕过大')
233
+ chunks.append(chunk)
234
+ content=json.loads(b''.join(chunks))
235
+ return {'available':True,'language':item.get('lan'),'text':'\n'.join(s.get('content','') for s in content.get('body',[]))[:12000]}
236
+ raise ValueError('未实现的操作')
237
+
238
+ @web.middleware
239
+ async def authentication(request,handler):
240
+ if not hmac.compare_digest(request.headers.get('Authorization',''),'Bearer '+TOKEN):return web.json_response({'error':'Unauthorized'},status=401)
241
+ return await handler(request)
242
+
243
+ async def call(request):
244
+ try:
245
+ body=await request.json(); channel=body['channelId']; op=body['operation'];p=body.get('params',{})
246
+ if not re.fullmatch(r'bilibili-[a-zA-Z0-9-]{1,80}',channel) or op not in READ|WRITE|ACCOUNT or not isinstance(p,dict):raise ValueError('请求无效')
247
+ if len(LOCKS)>500 and channel not in LOCKS:raise ValueError('账号数量超限')
248
+ async with LOCKS.setdefault(channel,asyncio.Lock()):
249
+ if op in ACCOUNT:return web.json_response(await asyncio.wait_for(account(channel,op),30))
250
+ if time.time()-LAST.get(channel,0)<2:raise ValueError('请求过快,请稍后再试')
251
+ LAST[channel]=time.time()
252
+ if op not in PUBLIC:
253
+ cred=credential(channel)
254
+ if not cred.sessdata or not await cred.check_valid():
255
+ return web.json_response({'error':'B 站登录已失效,请重新扫码;公开浏览仍可使用','code':'LOGIN_REQUIRED','notSubmitted':True},status=401)
256
+ rid=body.get('requestId')
257
+ if op in WRITE:
258
+ if not isinstance(rid,str) or not re.fullmatch('[a-f0-9]{64}',rid):raise ValueError('缺少操作幂等标识')
259
+ row=DB.execute('SELECT at,status,result FROM receipts WHERE channel=? AND id=?',(channel,rid)).fetchone()
260
+ if row and time.time()-row[0]<86400:
261
+ replay=json.loads(row[2]) if row[1]=='confirmed' else None
262
+ safe_retry=row[1]=='failed'
263
+ if replay and op in ('upload_draft','image_dynamic'):
264
+ job=replay.get('result',{}).get('id')
265
+ if job:
266
+ try:
267
+ old=MEDIA.load(channel,job)[1];safe_retry=old['status'] in ('failed','interrupted') and old.get('published') is False
268
+ except ValueError:pass
269
+ if not safe_retry:
270
+ if replay:return web.json_response(replay)
271
+ raise ValueError('该操作结果尚未确认,请勿重复提交')
272
+ DB.execute('INSERT OR REPLACE INTO receipts VALUES(?,?,?,?,?)',(channel,rid,time.time(),'pending',''));DB.commit()
273
+ try:result=bounded(await asyncio.wait_for(execute(channel,op,p),35))
274
+ except ValueError as e:
275
+ if op in ('upload_draft','image_dynamic'):
276
+ DB.execute('UPDATE receipts SET status=? WHERE channel=? AND id=?',('failed',channel,rid));DB.commit()
277
+ return web.json_response({'error':str(e)[:250],'notSubmitted':True},status=400)
278
+ raise
279
+ result={'result':result}
280
+ if op in WRITE:
281
+ DB.execute('UPDATE receipts SET status=?,result=? WHERE channel=? AND id=?',('confirmed',json.dumps(result),channel,rid));DB.commit()
282
+ DB.execute('DELETE FROM receipts WHERE at<?',(time.time()-7*86400,));DB.commit()
283
+ return web.json_response(result)
284
+ except asyncio.TimeoutError:return web.json_response({'error':'B 站请求超时;若为发布操作请先核实结果,勿重复发送'},status=504)
285
+ except Exception as e:
286
+ # Do not expose transport headers, cookies, or traceback to the model.
287
+ message=str(e) if isinstance(e,ValueError) else 'B 站接口暂不可用('+type(e).__name__+'),请稍后检查登录或连接'
288
+ return web.json_response({'error':message[:250]},status=400)
289
+
290
+ app=web.Application(middlewares=[authentication],client_max_size=32768)
291
+ app.router.add_post('/call',call)
292
+ async def media_endpoint(request):
293
+ try:
294
+ return await MEDIA.stage(request)
295
+ except ValueError as e:return web.json_response({'error':str(e)[:250]},status=400)
296
+ except Exception:return web.json_response({'error':'媒体传输失败'},status=500)
297
+ app.router.add_post('/stage/{channel}',media_endpoint)
298
+ app.router.add_post('/stage-images/{channel}',media_endpoint)
299
+ app.on_cleanup.append(MEDIA.close)
300
+ if __name__=='__main__':web.run_app(app,host='0.0.0.0',port=8091,access_log=None)
@@ -0,0 +1,115 @@
1
+ import unittest,tempfile,os,importlib.util,json,asyncio,sys
2
+ sys.path.insert(0,str(__import__('pathlib').Path(__file__).parent))
3
+ from pathlib import Path
4
+ from unittest.mock import AsyncMock,patch
5
+ from aiohttp.test_utils import TestClient,TestServer
6
+
7
+ class BridgeTest(unittest.IsolatedAsyncioTestCase):
8
+ async def asyncSetUp(self):
9
+ self.tmp=tempfile.TemporaryDirectory();root=Path(self.tmp.name);(root/'token').write_text('fixture-secret')
10
+ os.environ['BILI_DATA']=str(root/'state');os.environ['BILI_TOKEN_FILE']=str(root/'token')
11
+ spec=importlib.util.spec_from_file_location('bridge',Path(__file__).with_name('server.py'));self.m=importlib.util.module_from_spec(spec);spec.loader.exec_module(self.m)
12
+ self.client=TestClient(TestServer(self.m.app));await self.client.start_server()
13
+ async def asyncTearDown(self):
14
+ await self.client.close();self.m.DB.close();self.tmp.cleanup()
15
+ async def post(self,operation,**extra):return await self.client.post('/call',json={'channelId':'bilibili-test','operation':operation,'params':{},**extra},headers={'Authorization':'Bearer fixture-secret'})
16
+ async def test_private_bridge_and_public_share(self):
17
+ r=await self.client.post('/call',json={});self.assertEqual(r.status,401)
18
+ r=await self.post('share_link',params={'bvid':'BV1xx411c7mD'});self.assertEqual(r.status,200);self.assertFalse((await r.json())['result']['published'])
19
+ async def test_missing_login_blocks_write(self):
20
+ r=await self.post('like',params={'bvid':'BV1xx411c7mD','status':True},requestId='a'*64);self.assertEqual(r.status,401);self.assertIn('登录',(await r.json())['error'])
21
+ async def test_write_replay_is_deduplicated(self):
22
+ handler=AsyncMock(return_value={'rpid':123})
23
+ with patch.object(self.m,'execute',handler),patch.object(self.m,'credential',return_value=type('Cred',(),{'sessdata':'test','check_valid':AsyncMock(return_value=True)})()):
24
+ r=await self.post('comment',requestId='a'*64);self.assertEqual(r.status,200)
25
+ self.m.LAST.clear();r=await self.post('comment',requestId='a'*64);self.assertEqual(r.status,200);self.assertEqual(handler.await_count,1)
26
+ async def test_unknown_write_not_retried(self):
27
+ handler=AsyncMock(side_effect=asyncio.TimeoutError())
28
+ with patch.object(self.m,'execute',handler),patch.object(self.m,'credential',return_value=type('Cred',(),{'sessdata':'test','check_valid':AsyncMock(return_value=True)})()):
29
+ r=await self.post('comment',requestId='a'*64);self.assertEqual(r.status,504)
30
+ self.m.LAST.clear();r=await self.post('comment',requestId='a'*64);self.assertEqual(r.status,400);self.assertEqual(handler.await_count,1)
31
+ async def test_invalid_channel_cannot_escape_storage(self):
32
+ r=await self.post('status',channelId='../bad');self.assertEqual(r.status,400)
33
+ async def test_long_comment_ids_remain_exact(self):
34
+ self.assertEqual(self.m.bounded({'rpid':1234567890123456789})['rpid'],'1234567890123456789')
35
+ async def test_login_completes_without_browser_polling(self):
36
+ cred=type('Cred',(),{'sessdata':'fixture','bili_jct':'csrf','dedeuserid':'123','check_valid':AsyncMock(return_value=True)})()
37
+ qr=type('Qr',(),{'check_state':AsyncMock(return_value=self.m.QrCodeLoginEvents.DONE),'get_credential':lambda self:cred})()
38
+ self.m.LOGINS['bilibili-test']=(0,qr)
39
+ with patch.object(self.m.asyncio,'sleep',AsyncMock()),patch.object(self.m.user,'get_self_info',AsyncMock(return_value={'name':'测试账号','face':'https://i0.hdslb.com/test.jpg'})):
40
+ await self.m.monitor_login('bilibili-test',qr)
41
+ self.assertTrue((self.m.ROOT/'bilibili-test.json').exists())
42
+ r=await self.post('login_check');body=await r.json()
43
+ self.assertEqual(body['status'],'done');self.assertEqual(body['name'],'测试账号');self.assertNotIn('sessdata',body)
44
+ async def test_cookie_response_without_redirect_credentials(self):
45
+ from http.cookies import SimpleCookie
46
+ cookies=SimpleCookie();cookies['SESSDATA']='fixture-session';cookies['bili_jct']='fixture-csrf';cookies['DedeUserID']='123'
47
+ cred=self.m.login_credential({'url':'https://www.bilibili.com/','refresh_token':'fixture-refresh'},cookies)
48
+ self.assertTrue(cred.sessdata);self.assertEqual(cred.bili_jct,'fixture-csrf');self.assertEqual(cred.dedeuserid,'123')
49
+ async def test_legacy_redirect_credentials(self):
50
+ cred=self.m.login_credential({'url':'https://www.bilibili.com/?SESSDATA=fixture&bili_jct=csrf&DedeUserID=123'}, {})
51
+ self.assertEqual(cred.dedeuserid,'123');self.assertTrue(cred.sessdata)
52
+ async def test_incomplete_login_never_reports_success_or_saves(self):
53
+ cred=type('Cred',(),{'sessdata':None,'bili_jct':None})()
54
+ qr=type('Qr',(),{'check_state':AsyncMock(return_value=self.m.QrCodeLoginEvents.DONE),'get_credential':lambda self:cred})()
55
+ with patch.object(self.m.asyncio,'sleep',AsyncMock()):await self.m.monitor_login('bilibili-test',qr)
56
+ self.assertEqual(self.m.LOGIN_RESULTS['bilibili-test']['status'],'error')
57
+ self.assertFalse((self.m.ROOT/'bilibili-test.json').exists())
58
+ async def test_profile_failure_preserves_login(self):
59
+ cred=type('Cred',(),{'dedeuserid':'123'})()
60
+ with patch.object(self.m.user,'get_self_info',AsyncMock(side_effect=RuntimeError())):
61
+ result=await self.m.identity(cred)
62
+ self.assertEqual(result,{'logged_in':True,'uid':'123'})
63
+ async def test_subtitle_resolves_cid_before_query(self):
64
+ v=type('Video',(),{'get_cid':AsyncMock(return_value=456),'get_subtitle':AsyncMock(return_value={'subtitles':[]})})()
65
+ with patch.object(self.m.video,'Video',return_value=v):
66
+ r=await self.post('subtitle',params={'bvid':'BV1xx411c7mD'})
67
+ self.assertEqual(r.status,200);self.assertFalse((await r.json())['result']['available'])
68
+ v.get_cid.assert_awaited_once_with(page_index=0);v.get_subtitle.assert_awaited_once_with(cid=456)
69
+ async def test_empty_subtitle_metadata_is_not_an_error(self):
70
+ v=type('Video',(),{'get_cid':AsyncMock(return_value=456),'get_subtitle':AsyncMock(return_value=None)})()
71
+ with patch.object(self.m.video,'Video',return_value=v):
72
+ r=await self.post('subtitle',params={'bvid':'BV1xx411c7mD'})
73
+ self.assertEqual(r.status,200);self.assertFalse((await r.json())['result']['available'])
74
+ async def test_streaming_stage_keeps_uploads_private(self):
75
+ from aiohttp import FormData
76
+ form=FormData();form.add_field('video',b'x'*70000,filename='video.mp4');form.add_field('cover',b'fixture',filename='cover.png')
77
+ r=await self.client.post('/stage/bilibili-test',data=form,headers={'Authorization':'Bearer fixture-secret'})
78
+ self.assertEqual(r.status,200);jid=(await r.json())['stageId']
79
+ d,j=self.m.MEDIA.load('bilibili-test',jid);self.assertEqual((d/'video').stat().st_size,70000)
80
+ r=await self.post('upload_status',params={'job_id':jid});self.assertEqual(r.status,401)
81
+ async def test_video_stream_returns_links_without_downloading(self):
82
+ v=type('Video',(),{'get_download_url':AsyncMock(return_value={'durl':[{'url':'https://upos-sz-mirrorcos.bilivideo.com/video.mp4','length':10000}]})})()
83
+ with patch.object(self.m.video,'Video',return_value=v):
84
+ r=await self.post('video_stream',params={'bvid':'BV1xx411c7mD','page':2})
85
+ result=(await r.json())['result'];self.assertEqual(r.status,200);self.assertFalse(result['downloaded']);self.assertEqual(result['mode'],'progressive')
86
+ v.get_download_url.assert_awaited_once_with(page_index=1,html5=True)
87
+ self.assertEqual(list(self.m.MEDIA.root.glob('*/*')),[])
88
+ async def test_video_stream_exposes_separate_audio_and_official_backup(self):
89
+ v=type('Video',(),{'get_download_url':AsyncMock(return_value={'dash':{'video':[{'baseUrl':'https://cdn.bilivideo.cn:8082/a','backupUrl':['https://upos-sz-mirrorcos.bilivideo.com/video'],'codecs':'avc1'}],'audio':[{'baseUrl':'https://upos-sz-mirrorcos.bilivideo.com/audio'}]}})})()
90
+ with patch.object(self.m.video,'Video',return_value=v):r=await self.post('video_stream',params={'bvid':'BV1xx411c7mD'})
91
+ result=(await r.json())['result'];self.assertEqual(result['mode'],'dash');self.assertTrue(result['audio']['url'].endswith('/audio'));self.assertFalse(result['interpreted'])
92
+ async def test_logout_keeps_public_mode(self):
93
+ r=await self.post('logout');self.assertEqual(r.status,200)
94
+ r=await self.post('status');self.assertFalse((await r.json())['logged_in'])
95
+ async def test_failed_media_job_can_retry_but_unknown_job_cannot(self):
96
+ jid='b'*32;handler=AsyncMock(return_value={'id':jid,'status':'queued','published':False})
97
+ cred=type('Cred',(),{'sessdata':'test','check_valid':AsyncMock(return_value=True)})()
98
+ with patch.object(self.m,'execute',handler),patch.object(self.m,'credential',return_value=cred):
99
+ self.assertEqual((await self.post('image_dynamic',requestId='b'*64)).status,200)
100
+ d=self.m.MEDIA.directory('bilibili-test',jid)
101
+ self.m.MEDIA.save(d,{'id':jid,'status':'failed','published':False})
102
+ self.m.LAST.clear();self.assertEqual((await self.post('image_dynamic',requestId='b'*64)).status,200);self.assertEqual(handler.await_count,2)
103
+ self.m.MEDIA.save(d,{'id':jid,'status':'unknown','published':False})
104
+ self.m.LAST.clear();await self.post('image_dynamic',requestId='b'*64);self.assertEqual(handler.await_count,2)
105
+ async def test_image_stage_validates_content_and_order(self):
106
+ from aiohttp import FormData
107
+ from PIL import Image
108
+ from io import BytesIO
109
+ pic=BytesIO();Image.new('RGB',(16,16)).save(pic,format='PNG')
110
+ for names,expected in [(['image0','image1'],200),(['image1'],400),(['../bad'],400)]:
111
+ form=FormData()
112
+ for name in names:form.add_field(name,pic.getvalue(),filename='image.png',content_type='image/png')
113
+ response=await self.client.post('/stage-images/bilibili-test',data=form,headers={'Authorization':'Bearer fixture-secret'})
114
+ self.assertEqual(response.status,expected)
115
+ if __name__=='__main__':unittest.main()
@@ -0,0 +1,110 @@
1
+ import asyncio,json,tempfile,unittest,sys,subprocess
2
+ from pathlib import Path
3
+ from unittest.mock import AsyncMock,patch
4
+ sys.path.insert(0,str(Path(__file__).parent))
5
+ from media import Media,UploadOnly,media_url
6
+
7
+ class MediaTest(unittest.IsolatedAsyncioTestCase):
8
+ async def asyncSetUp(self):
9
+ self.tmp=tempfile.TemporaryDirectory();self.root=Path(self.tmp.name)
10
+ self.cred=type('Cred',(),{'dedeuserid':'123','bili_jct':'fixture','check_valid':AsyncMock(return_value=True)})()
11
+ self.m=Media(self.root,lambda _:self.cred);self.channel='bilibili-test';self.jid='a'*32
12
+ async def asyncTearDown(self):
13
+ await self.m.close(None);self.tmp.cleanup()
14
+ def job(self,status='draft',op='upload_draft'):
15
+ d=self.m.directory(self.channel,self.jid);j={'id':self.jid,'operation':op,'status':status,'uid':'123','createdAt':__import__('time').time(),'meta':{'title':'fixture'},'uploaded':{'videos':[{'filename':'fixture'}],'cover':'https://i0.hdslb.com/fixture.jpg'}};self.m.save(d,j);return d,j
16
+ async def test_upload_override_never_publishes(self):
17
+ result=await UploadOnly._submit(object(),[{'filename':'fixture','cid':1}],'cover')
18
+ self.assertEqual(result,{'videos':[{'filename':'fixture','cid':1}],'cover':'cover'})
19
+ async def test_publish_requires_matching_account_and_ready_draft(self):
20
+ self.job();self.cred.dedeuserid='456'
21
+ with self.assertRaisesRegex(ValueError,'不属于'):await self.m.publish(self.channel,{'draft_id':self.jid})
22
+ self.cred.dedeuserid='123';self.job('running')
23
+ with self.assertRaisesRegex(ValueError,'未就绪'):await self.m.publish(self.channel,{'draft_id':self.jid})
24
+ async def test_publish_returns_receipt_and_prevents_repeat(self):
25
+ self.job()
26
+ class Fake:
27
+ def __init__(self,**kw):pass
28
+ def update_params(self,**kw):return self
29
+ def update_data(self,**kw):self.data=kw;return self
30
+ @property
31
+ def result(self):return AsyncMock(return_value={'bvid':'BV1xx411c7mD','aid':1})()
32
+ with patch('media.Api',Fake):r=await self.m.publish(self.channel,{'draft_id':self.jid})
33
+ self.assertEqual(r['status'],'submitted');self.assertTrue(r['published'])
34
+ with self.assertRaises(ValueError):await self.m.publish(self.channel,{'draft_id':self.jid})
35
+ async def test_unknown_publish_is_durable(self):
36
+ self.job()
37
+ with patch('media.Api',side_effect=TimeoutError()):
38
+ with self.assertRaises(TimeoutError):await self.m.publish(self.channel,{'draft_id':self.jid})
39
+ self.assertEqual(self.m.load(self.channel,self.jid)[1]['status'],'unknown')
40
+ async def test_restart_does_not_replay_submission(self):
41
+ self.job('submitting');m=Media(self.root,lambda _:self.cred)
42
+ self.assertEqual(m.load(self.channel,self.jid)[1]['status'],'unknown')
43
+ async def test_logout_cancels_inflight_upload(self):
44
+ gate=asyncio.Event()
45
+ async def wait(*args):await gate.wait()
46
+ d,j=self.job('queued')
47
+ (d/'video').write_bytes(b'fixture')
48
+ from PIL import Image
49
+ Image.new('RGB',(16,16)).save(d/'cover',format='PNG')
50
+ with patch('media.UploadOnly') as uploader:
51
+ uploader.return_value.start=AsyncMock(side_effect=wait)
52
+ task=asyncio.create_task(self.m.run(self.channel,d,j,{'title':'test','tid':1,'tags':['test']},self.cred));self.m.tasks[self.jid]=(self.channel,task)
53
+ await asyncio.sleep(0);await self.m.cancel(self.channel)
54
+ self.assertEqual(self.m.load(self.channel,self.jid)[1]['status'],'interrupted')
55
+ async def test_channel_isolation_and_url_restrictions(self):
56
+ self.job()
57
+ with self.assertRaises(ValueError):self.m.load('bilibili-other',self.jid)
58
+ for url in ['file:///etc/passwd','http://cdn.bilivideo.com/a','https://localhost/a','https://cdn.bilivideo.com.evil/a','https://user:pass@cdn.bilivideo.com/a','https://cdn.bilivideo.com:8443/a']:
59
+ with self.assertRaises(ValueError):media_url(url)
60
+ self.assertEqual(media_url('https://upos-sz-mirrorcos.bilivideo.com/a'),'https://upos-sz-mirrorcos.bilivideo.com/a')
61
+ async def test_probe_real_video_and_reject_text(self):
62
+ path=self.root/'small.mp4'
63
+ subprocess.run(['ffmpeg','-v','error','-f','lavfi','-i','color=c=black:s=64x64:d=0.1','-c:v','mpeg4',str(path)],check=True)
64
+ await self.m.probe(path)
65
+ path.write_text('not a video')
66
+ with self.assertRaises(ValueError):await self.m.probe(path)
67
+ async def test_rate_limit_preserves_code_and_persists_cooldown_without_uploading(self):
68
+ from media import UploadRejected
69
+ uploader=object.__new__(UploadOnly);uploader.line={'os':'upos','upcdn':'bda2','probe_version':'20211012'}
70
+ uploader.credential=type('Cred',(),{'get_buvid_cookies':AsyncMock(return_value={})})()
71
+ response=type('Response',(),{'code':406,'json':lambda _: {'OK':0,'code':601,'message':'secret-token-should-not-escape'}})()
72
+ client=type('Client',(),{'request':AsyncMock(return_value=response)})()
73
+ page=type('Page',(),{'path':'video.mp4','get_size':lambda _:100})()
74
+ with patch('media.get_client',return_value=client):
75
+ with self.assertRaises(UploadRejected) as caught:await uploader._preupload(page)
76
+ self.assertEqual(client.request.await_count,1)
77
+ self.assertEqual(caught.exception.details['code'],601);self.assertNotIn('secret-token',str(caught.exception))
78
+ d,j=self.job();self.m.record_failure(self.channel,j,caught.exception)
79
+ self.assertTrue(self.m.cooldown(self.channel));self.assertGreater(j['retryAt'],__import__('time').time())
80
+ restarted=Media(self.root,lambda _:self.cred)
81
+ with self.assertRaisesRegex(ValueError,'冷却'):await restarted.start(self.channel,'upload_draft',{})
82
+ async def test_ordered_images_publish_once_and_unknown_submission_stays_unknown(self):
83
+ from PIL import Image
84
+ for fail in (False,True):
85
+ d,j=self.job('queued','image_dynamic');j.update(imageCount=2,published=False)
86
+ for i in range(2):Image.new('RGB',(16+i,16)).save(d/f'image{i}',format='PNG')
87
+ seen=[]
88
+ async def upload(pic,cred):
89
+ seen.append((pic.width,pic._to_biliapifile().path));return {'image_url':'http://i0.hdslb.com/test.png'}
90
+ class Fake:
91
+ def __init__(self,**kw):pass
92
+ def update_data(self,**kw):
93
+ assert kw['dyn_req']['pics'][0]['img_src'].startswith('https://')
94
+ self.data=kw;return self
95
+ def update_params(self,**kw):return self
96
+ @property
97
+ def result(self):return AsyncMock(side_effect=TimeoutError() if fail else None,return_value={'dyn_id_str':'1234567890123456789'})()
98
+ with patch('media.dynamic.upload_image',side_effect=upload),patch('media.Api',Fake):await self.m.run_images(self.channel,d,j,{'text':'漫画'},self.cred)
99
+ saved=self.m.load(self.channel,self.jid)[1]
100
+ self.assertEqual(saved['status'],'unknown' if fail else 'submitted')
101
+ self.assertEqual([x[0] for x in seen],[16,17]);self.assertNotEqual(seen[0][1],seen[1][1])
102
+ self.assertFalse((d/'image0').exists())
103
+ if not fail:self.assertEqual(saved['dynamic_id'],'1234567890123456789')
104
+ async def test_image_upload_failure_never_calls_dynamic_publish(self):
105
+ from PIL import Image
106
+ d,j=self.job('queued','image_dynamic');j.update(imageCount=1,published=False);Image.new('RGB',(16,16)).save(d/'image0',format='PNG')
107
+ with patch('media.dynamic.upload_image',side_effect=ValueError('图片上传被拒绝')),patch('media.Api') as api:
108
+ await self.m.run_images(self.channel,d,j,{},self.cred);api.assert_not_called()
109
+ self.assertEqual(self.m.load(self.channel,self.jid)[1]['status'],'failed')
110
+ if __name__=='__main__':unittest.main()