├── .gitignore ├── README.md ├── doc ├── Makefile ├── conf.py ├── framework.rst ├── index.rst ├── qq.rst └── seq.rst └── src ├── chatloggin.conf ├── client.py ├── qqsetting.py └── webqq.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | 29 | # Translations 30 | *.mo 31 | 32 | # Mr Developer 33 | .mr.developer.cfg 34 | .project 35 | .pydevproject 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | webqq-console 2 | ============= 3 | 4 | webqq 3.0协议的实现,比较完整的实现了主要的核心功能 5 | 6 | 基于Python语言的gevent实现, 单线程,比较省资源 7 | 8 | **该项目已经停止更新,且已经不能使用** 9 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/webqq.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/webqq.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/webqq" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/webqq" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # webqq documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Sep 21 15:16:27 2012. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.autodoc'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'webqq' 44 | copyright = u'2012, alex8224@gmail.com' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = '1' 52 | # The full version, including alpha/beta/rc tags. 53 | release = '1' 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | 58 | # There are two options for replacing |today|: either, you set today to some 59 | # non-false value, then it is used: 60 | #today = '' 61 | # Else, today_fmt is used as the format for a strftime call. 62 | #today_fmt = '%B %d, %Y' 63 | 64 | # List of patterns, relative to source directory, that match files and 65 | # directories to ignore when looking for source files. 66 | exclude_patterns = [] 67 | 68 | # The reST default role (used for this markup: `text`) to use for all documents. 69 | #default_role = None 70 | 71 | # If true, '()' will be appended to :func: etc. cross-reference text. 72 | #add_function_parentheses = True 73 | 74 | # If true, the current module name will be prepended to all description 75 | # unit titles (such as .. function::). 76 | #add_module_names = True 77 | 78 | # If true, sectionauthor and moduleauthor directives will be shown in the 79 | # output. They are ignored by default. 80 | #show_authors = False 81 | 82 | # The name of the Pygments (syntax highlighting) style to use. 83 | pygments_style = 'sphinx' 84 | 85 | # A list of ignored prefixes for module index sorting. 86 | #modindex_common_prefix = [] 87 | 88 | 89 | # -- Options for HTML output --------------------------------------------------- 90 | 91 | # The theme to use for HTML and HTML Help pages. See the documentation for 92 | # a list of builtin themes. 93 | html_theme = 'nature' 94 | 95 | # Theme options are theme-specific and customize the look and feel of a theme 96 | # further. For a list of options available for each theme, see the 97 | # documentation. 98 | #html_theme_options = {} 99 | 100 | # Add any paths that contain custom themes here, relative to this directory. 101 | #html_theme_path = [] 102 | 103 | # The name for this set of Sphinx documents. If None, it defaults to 104 | # " v documentation". 105 | #html_title = None 106 | 107 | # A shorter title for the navigation bar. Default is the same as html_title. 108 | #html_short_title = None 109 | 110 | # The name of an image file (relative to this directory) to place at the top 111 | # of the sidebar. 112 | #html_logo = None 113 | 114 | # The name of an image file (within the static path) to use as favicon of the 115 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 116 | # pixels large. 117 | #html_favicon = None 118 | 119 | # Add any paths that contain custom static files (such as style sheets) here, 120 | # relative to this directory. They are copied after the builtin static files, 121 | # so a file named "default.css" will overwrite the builtin "default.css". 122 | html_static_path = ['_static'] 123 | 124 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 125 | # using the given strftime format. 126 | #html_last_updated_fmt = '%b %d, %Y' 127 | 128 | # If true, SmartyPants will be used to convert quotes and dashes to 129 | # typographically correct entities. 130 | #html_use_smartypants = True 131 | 132 | # Custom sidebar templates, maps document names to template names. 133 | #html_sidebars = {} 134 | 135 | # Additional templates that should be rendered to pages, maps page names to 136 | # template names. 137 | #html_additional_pages = {} 138 | 139 | # If false, no module index is generated. 140 | #html_domain_indices = True 141 | 142 | # If false, no index is generated. 143 | #html_use_index = True 144 | 145 | # If true, the index is split into individual pages for each letter. 146 | #html_split_index = False 147 | 148 | # If true, links to the reST sources are added to the pages. 149 | #html_show_sourcelink = True 150 | 151 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 152 | #html_show_sphinx = True 153 | 154 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 155 | #html_show_copyright = True 156 | 157 | # If true, an OpenSearch description file will be output, and all pages will 158 | # contain a tag referring to it. The value of this option must be the 159 | # base URL from which the finished HTML is served. 160 | #html_use_opensearch = '' 161 | 162 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 163 | #html_file_suffix = None 164 | 165 | # Output file base name for HTML help builder. 166 | htmlhelp_basename = 'webqqdoc' 167 | 168 | 169 | # -- Options for LaTeX output -------------------------------------------------- 170 | 171 | # The paper size ('letter' or 'a4'). 172 | #latex_paper_size = 'letter' 173 | 174 | # The font size ('10pt', '11pt' or '12pt'). 175 | #latex_font_size = '10pt' 176 | 177 | # Grouping the document tree into LaTeX files. List of tuples 178 | # (source start file, target name, title, author, documentclass [howto/manual]). 179 | latex_documents = [ 180 | ('index', 'webqq.tex', u'webqq Documentation', 181 | u'alex8224@gmail.com', 'manual'), 182 | ] 183 | 184 | latex_elements = { 185 | # Additional stuff for the LaTeX preamble. 186 | 'preamble': ''' 187 | \usepackage{xeCJK} 188 | \setCJKmainfont[BoldFont=SimHei, ItalicFont=KaiTi_GB2312]{SimSun} 189 | \setCJKmonofont[Scale=0.9]{Droid Sans Mono} 190 | \setCJKfamilyfont{song}[BoldFont=SimSun]{SimSun} 191 | \setCJKfamilyfont{sf}[BoldFont=SimSun]{SimSun} 192 | ''', 193 | } 194 | 195 | # The name of an image file (relative to this directory) to place at the top of 196 | # the title page. 197 | #latex_logo = None 198 | 199 | # For "manual" documents, if this is true, then toplevel headings are parts, 200 | # not chapters. 201 | #latex_use_parts = False 202 | 203 | # If true, show page references after internal links. 204 | #latex_show_pagerefs = False 205 | 206 | # If true, show URL addresses after external links. 207 | #latex_show_urls = False 208 | 209 | # Additional stuff for the LaTeX preamble. 210 | #latex_preamble = '' 211 | 212 | # Documents to append as an appendix to all manuals. 213 | #latex_appendices = [] 214 | 215 | # If false, no module index is generated. 216 | #latex_domain_indices = True 217 | 218 | 219 | # -- Options for manual page output -------------------------------------------- 220 | 221 | # One entry per manual page. List of tuples 222 | # (source start file, name, description, authors, manual section). 223 | man_pages = [ 224 | ('index', 'webqq', u'webqq Documentation', 225 | [u'alex8224@gmail.com'], 1) 226 | ] 227 | -------------------------------------------------------------------------------- /doc/framework.rst: -------------------------------------------------------------------------------- 1 | Open Api 框架设计 2 | ================== 3 | 4 | 目的 5 | ------ 6 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | .. webqq documentation master file, created by 2 | sphinx-quickstart on Fri Sep 21 15:16:27 2012. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to webqq's documentation! 7 | ================================= 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | qq 15 | seq 16 | framework 17 | 18 | Indices and tables 19 | ================== 20 | 21 | * :ref:`genindex` 22 | * :ref:`modindex` 23 | * :ref:`search` 24 | 25 | -------------------------------------------------------------------------------- /doc/qq.rst: -------------------------------------------------------------------------------- 1 | webqq3.0 协议分析 2 | =================== 3 | 4 | 生成密码 5 | ---------- 6 | 7 | 访问如下的url **http://check.ptlogin2.qq.com/check?uin=qqnumber&appid=1003903&r=0.09714xxxx** 得到如下信息 8 | 9 | .. code-block:: javascript 10 | 11 | pt_checkVC('0', '!B55', '\x00\x00\x00\x00\xa6\xce\xef\xfe') 12 | 13 | 1. 当前用户是否需要使用验证码登陆 14 | 15 | 2. 提取第二三个字段作为加密的salt 16 | 17 | 生成密码的步骤如下: 18 | 19 | 1. 得到uin 的字节数组 20 | 21 | 2. 将密码md5 后转换为字节数组 22 | 23 | 3. 组合密码的字节数组和uin 的字节数组,密码在前 24 | 25 | 4. 将组合后的字节数组进行md5 26 | 27 | 5. 将最后一次md5 的hexstr+vcode,在进行一次 md5 28 | 29 | qq 密码的计算方法如下 30 | 31 | .. code-block:: python 32 | 33 | md5(md5((md5("1234567890").digest()+uin)).hexdigest().upper()+"!B55").hexdigest().upper() 34 | 35 | .. _2ndlogin: 36 | 37 | 使用生成的密码二次登陆 38 | ------------------------ 39 | 40 | 使用上次生成的密码字符串拼接如下url,进行二次登陆: 41 | 42 | **http://ptlogin2.qq.com/login?u=10897944&p=07B8A85663FAB28A25CEFEC495AD15CB&verifycode=!WD3&webqq_type=10&remember_uin=1&login2qq=1&aid=1003903&u1=http%3A%2F%2Fwebqq.qq.com%2Floginproxy.html%3Flogin2qq%3D1%26webqq_type%3D10&h=1&ptredirect=0&ptlang=2052&from_ui=1&pttype=1&dumy=&fp=loginerroralert&action=1-20-8656&mibao_css=m_webqq&t=1&g=1** 43 | 44 | 1. 发送上面的请求后,从返回的cookie中获取 `ptwebqq` 参数, 保存起来 45 | 46 | 2. 发送过来的cookie都要保存起来,后面在请求的时候发送给 webqq 47 | 48 | 3. 组装二次登陆的 http://d.web2.qq.com/channel/login2,里面用到了ptwebqq参数, 需要注意的是本次需要发送的是 `POST` 请求: 49 | 50 | **r=%7B%22status%22%3A%22online%22%2C%22ptwebqq%22%3A%2272541a7b79772b8f09f72261b30e82b27e7712f44fa76884231d47b4d2894dc3%22%2C%22passwd_sig%22%3A%22%22%2C%22clientid%22%3A%2232383579%22%2C%22psessionid%22%3Anull%7D&clientid=32383579&psessionid=null** 51 | 52 | 53 | 实际的内容其实是两个字段,如下 54 | 55 | .. code-block:: haskell 56 | 57 | clientid: xxxxxxx 58 | 59 | psessionid: null 60 | 61 | r: {"status":"online","ptwebqq":"72541a7b79772b8f09f72261b30e82b27e7712f44fa76884231d47b4d2894dc3","passwd_sig":"","clientid":"32383579","psessionid":null} 62 | 63 | 64 | 对上面的内容进行urlencode 后发送到服务器 65 | 66 | 4. 发送请求之前需要在请求中增加 http header Referer: http://d.web2.qq.com/proxy.html?v=20110331002&callback=2 67 | 68 | 5. 从返回的json值中提取登陆的结果, 并把 `psessionid` 和 `vfwebqq` 保存起来,后面发送请求的时候要使用, 返回值如下 69 | 70 | .. code-block:: javascript 71 | 72 | {"retcode":0, 73 | "result": 74 | { 75 | "uin":10897944,"cip":1959559061,"index":1075,"port":40036,"status":"online", 76 | "vfwebqq":"963856c05954b2f1a0b1f4efff16cc605ce3a1b84792ac678dee4b919c1a", 77 | "psessionid":"c53856c05954b2f1a0b1f4efff16cc605ce3a1b84792ac678dee4b919c1a","user_state":0,"f":0 78 | } 79 | } 80 | 81 | 82 | 获取朋友的列表 83 | ---------------- 84 | 85 | 登陆成功后获取自己的朋友列表,需要组装下列 **POST** 请求发送到 http://s.web2.qq.com/api/get_user_friend2 86 | 87 | 需要设置 Referer 参数为 http://d.web2.qq.com/proxy.html?v=20110331002&callback=2 88 | 89 | .. code-block:: javascript 90 | 91 | r={"h":"hello","vfwebqq":"9635b7bdfb20a7d08f43c53856c05954b2f1a0b1f4efff16cc605ce3a1b84792ac678dee4b919c1a"} 92 | 93 | 需要使用将上面的参数编码后发送, 94 | 95 | **r=%7B%22h%22%3A%22hello%22%2C%22vfwebqq%22%3A%229635b7bdfb20a7d08f43c53856c05954b2f1a0b1f4efff16cc605ce3a1b84792ac678dee4b919c1a%22%7D** 96 | 97 | 98 | 得到的返回值为json格式,基本结构如下 99 | 100 | .. code-block:: javascript 101 | 102 | {"retcode":0, "result":{"marknames",[], "info":[], "vipinfo":[], "categories":[]}} 103 | 104 | ``参数的解释:`` 105 | 106 | +-------------------+-----------------------------+---------------------------------------------+ 107 | | 参数名称 | 参数描述 | 返回值结构 | 108 | +===================+=============================+=============================================+ 109 | | returncode | 返回码,为 0 时表示成功 | 无 | 110 | +-------------------+-----------------------------+---------------------------------------------+ 111 | | marknames | 表示加了备注的好友,结构如下 | {"markname":"", "uin":""} | 112 | | | {'markname':"", "uin":} | | 113 | +-------------------+-----------------------------+---------------------------------------------+ 114 | | info | 存放所有好友和uin的对应关系 | [{"nick":"", "flag":"","uin":"","face":""}] | 115 | +-------------------+-----------------------------+---------------------------------------------+ 116 | | vipinfo | 存放所有好友的vip级别 | 不做描述 | 117 | +-------------------+-----------------------------+---------------------------------------------+ 118 | | categories | 好友和uin 的对应关系,用户 | {"sort":1, "index":1,"name":""} | 119 | | | 分组信息 | | 120 | +-------------------+-----------------------------+---------------------------------------------+ 121 | 122 | 123 | .. _receivemsg: 124 | 125 | 接收消息 126 | --------- 127 | 使用链接 http://d.web2.qq.com/channel/poll2 发送 `POST` 请求轮询好友发送的消息 128 | 129 | **clientid** 是一个long型的整数,一般写一个就行了,后面可以重复使用 130 | 131 | **POST 过去的参数都必须先进行编码后发送** 132 | 133 | 必须设置http header **Referer:http://d.web2.qq.com/proxy.html?v=20110331002&callback=1&id=3** 这个值目前是固定的 134 | 135 | 轮询消息也不需要有cookies的支持 136 | 137 | .. code-block:: haskell 138 | 139 | clientid=32383579 140 | psessionid=54b2f1a0b1f4efff16cc605ce3a1b84792ac678dee4b919c1a 141 | r={"clientid":"32383579","psessionid":"54b2f1a0b1f4efff16cc605ce3a1b84792ac678dee4b919c1a","key":0,"ids":[]} 142 | 143 | 返回值为json格式 144 | 145 | .. code-block:: javascript 146 | 147 | {"retcode":0,"result":[{"poll_type":"buddies_status_change","value":{"uin":3983012188,"status":"online","client_type":1}}]} 148 | 149 | retcode 为 0 才可以获取后续的值, 具体的消息类型通过result字段的 **poll_type** 的值决定, **poll_type** 的可选值如下表: 150 | 151 | +-------------------------+------------------------------------------------------------------+ 152 | | poll_type | 描述 | 153 | +=========================+==================================================================+ 154 | | buddies_status_change | 用户的在线状态发生改变 | 155 | +-------------------------+------------------------------------------------------------------+ 156 | | message | 收到用户发送的消息 | 157 | +-------------------------+------------------------------------------------------------------+ 158 | | kick_message | 同一个账号在另外的地方登陆,客户端收到后应该断开与服务器的连接 | 159 | +-------------------------+------------------------------------------------------------------+ 160 | 161 | result 是一个数组,所以里面可以包含多个不同 **poll_type** 的消息 162 | 163 | buddies_status_change 消息的结构如下: 164 | 165 | .. code-block:: javascript 166 | 167 | {"poll_type":"buddies_status_change", "value":{"uin":xxxxxxx,"status":"online","client_type":1}} 168 | 169 | message 消息的结构如下: 170 | 171 | .. code-block:: javascript 172 | 173 | {'poll_type': 'message', 174 | 'value': 175 | { 176 | 'reply_ip': 176498310, 'msg_type': 9, 'msg_id': 10171, 177 | 'content': [ 178 | [ 179 | 'font', {'color': '000000', 'style': [0, 0, 0], 'name': '\u5b8b\u4f53', 'size': 9} 180 | ] , '\u4e2d\u5348\u5462\r' 181 | ], 182 | 'msg_id2': 158459, 'from_uin': 3898449591L, 'time': 1348566488, 'to_uin': 10897944 183 | } 184 | } 185 | 186 | kick_message 消息的结构如下: 187 | 188 | .. code-block:: javascript 189 | 190 | { 191 | 'poll_type': 'kick_message', 192 | 'value': 193 | { 194 | 'reply_ip': 0, 195 | 'msg_type': 48, 196 | 'msg_id': 30519, 197 | 'reason': 'xxxx for force logout', 198 | 'msg_id2': 30520, 199 | 'from_uin': 10000, 200 | 'show_reason': 1, 201 | 'to_uin': 10897944 202 | } 203 | } 204 | 205 | 206 | 发送消息给好友 207 | --------------- 208 | 发送 `POST` 请求到链接 http://d.web2.qq.com/channel/send_buddy_msg2 ,并提交以下内容到服务器即可, 需要注意的是发送的内容要进行 ``url编码`` 之后发送 209 | 210 | 发送消息时不需要cookie的支持,服务器只识别clientid和psessionid这两个参数 211 | 212 | 必须设置http header **Referer:http://d.web2.qq.com/proxy.html?v=20110331002&callback=1&id=3** 这个值目前是固定的 213 | 214 | .. code-block:: javascript 215 | 216 | clientid=44597165 217 | psessionid=8304fbcd9008992d818c910636a81146633f3bdd6b8e0a53b910d59b40e521dd924fb9 218 | r={"to":2481546577, 219 | "face":177, 220 | "content":"[\"好快,就到中午了\\n\",[\"font\",{\"name\":\"宋体\",\"size\":\"10\",\"style\":[0,0,0],\"color\":\"000000\"}]]", 221 | "msg_id":85970004, 222 | "clientid":"44597165", 223 | "psessionid":"8304fbcd9008992d818c910636a81146633f3bdd6b8e0a53b910d59b40e521dd924fb9"} 224 | 225 | clientid 的解释参考 :ref:`receivemsg` 小节的解释 226 | 227 | psessionid 是 :ref:`2ndlogin` 成功之后服务器返回的唯一参数,会话的过程中都要带上这个参数 228 | 229 | content 的基础结构如下: 230 | 231 | .. code-block:: javascript 232 | 233 | ["msgbody", 234 | ["font", 235 | {"name":"宋体", "size":"10", "style":[0,0,0], "color":"000000"} 236 | ] 237 | ] 238 | 239 | r 参数的详细解释如下表: 240 | 241 | +-------------------+-------------------------------+ 242 | | 参数名称 | 参数描述 | 243 | +===================+===============================+ 244 | | to | 发送消息给朋友,uin 在这里 | 245 | | | 不是指朋友的qq号码,每次不同 | 246 | +-------------------+-------------------------------+ 247 | | face | 可能是表情的编号,具体意思未知| 248 | +-------------------+-------------------------------+ 249 | | content | 发送给朋友的消息和格式描述 | 250 | +-------------------+-------------------------------+ 251 | | clientid | 参考 :ref:`receivemsg` | 252 | +-------------------+-------------------------------+ 253 | | psessionid | 参考 :ref:`2ndlogin` | 254 | +-------------------+-------------------------------+ 255 | 256 | 257 | 发送群消息 258 | ------------ 259 | 260 | 261 | 获取好友的详细信息 262 | ------------------- 263 | 264 | 265 | 心跳维护 266 | ----------- 267 | 周期性的发送 `GET` 请求到 url http://webqq.qq.com/web2/get_msg_tip?uin=&tp=1&id=0&retype=1&rc=1&lv=3&t=1348458711542 维持与qq服务器的连接 268 | 269 | 改变登陆状态 270 | ------------- 271 | 发送 `GET` 请求到 url http://d.web2.qq.com/channel/change_status2?newstatus=hidden&clientid=44597165&psessionid=aac22e218a25034e1e1d9ed142c52168005f5983&t=1348482231366 272 | 273 | 这个也不需要cookie的支持,但是clientid和psessionid要正确填写 274 | 275 | 参数解释: 276 | 277 | +-------------------+-------------------------------+ 278 | | 参数名称 | 参数描述 | 279 | +===================+===============================+ 280 | | newstatus | 要改变的状态,可选值: | 281 | | | 1. hidden 2. online 3. away | 282 | | | 4. busy 5. offline | 283 | +-------------------+-------------------------------+ 284 | | clientid | 参考 :ref:`receivemsg` | 285 | +-------------------+-------------------------------+ 286 | | psessionid | 参考 :ref:`2ndlogin` | 287 | +-------------------+-------------------------------+ 288 | | t | 基于时间的随机数 | 289 | +-------------------+-------------------------------+ 290 | 291 | 292 | 返回的是 json 格式数据 293 | 294 | .. code-block:: javascript 295 | 296 | {"retcode":0,"result":"ok"} 297 | 298 | 299 | 注销登陆 300 | ------------ 301 | 302 | 303 | webqq返回码解释 304 | ---------------- 305 | 306 | +------------+-----------------------------------------------+ 307 | | 返回码 | 意义 | 308 | +============+===============================================+ 309 | | 102 | 轮询消息超时 | 310 | +------------+-----------------------------------------------+ 311 | | 0 | 成功 | 312 | +------------+-----------------------------------------------+ 313 | | 116 | 通知更新ptwebqq的值 | 314 | +------------+-----------------------------------------------+ 315 | 316 | -------------------------------------------------------------------------------- /doc/seq.rst: -------------------------------------------------------------------------------- 1 | 编码流程分析 2 | ============== 3 | 4 | 整个系统复用 登陆时获取的 ptwebqq, vfwebqq, psessionid 参数 5 | 6 | clientid 可以制定一个固定的值 7 | 8 | 设计考虑 ``开放性`` 9 | 10 | 登陆 11 | ------ 12 | 13 | 1. 对用户密码进行加密 14 | 15 | 2. 获取ptwebqq和vfwebqq 的值 16 | 17 | 3. 进行二次登陆,获取psessionid 18 | 19 | 消息事件循环 20 | ------------ 21 | 22 | 1. 轮询消息,等待消息 23 | 24 | 2. 判断消息类型,调用相关的回调函数处理 25 | 26 | 心跳循环 27 | ---------- 28 | 29 | 1. 一分钟发送一次心跳消息,维持与服务器的连接 30 | 31 | 2. 可能需要携带 ``cookies`` 的信息,需要复用登陆的那个 ``request`` 32 | 33 | 34 | 消息发送循环 35 | ------------- 36 | 37 | 1. 弹出队列的消息,并解析消息为具体的类型 38 | 39 | 2. 调用请求发送消息 40 | 41 | 3. 休眠一小会,防止用户输入速度过快导致发送消息失败 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/chatloggin.conf: -------------------------------------------------------------------------------- 1 | 2 | [loggers] 3 | keys=root,buddies_status 4 | 5 | [handlers] 6 | keys=consoleHandler,rotateFileHandler, NotifyHandler 7 | 8 | [formatters] 9 | keys=simpleFormatter, simpleFormatter1 10 | 11 | [formatter_simpleFormatter] 12 | #format=[%(asctime)s] : %(message)s 13 | format=%(message)s 14 | 15 | [formatter_simpleFormatter1] 16 | format=[%(asctime)s] : %(message)s 17 | 18 | [logger_root] 19 | level=DEBUG 20 | handlers=consoleHandler,rotateFileHandler 21 | qualname=example 22 | propagate=0 23 | 24 | [logger_buddies_status] 25 | level=DEBUG 26 | handlers=NotifyHandler 27 | qualname=example 28 | propagate=0 29 | 30 | 31 | [handler_consoleHandler] 32 | class=StreamHandler 33 | level=DEBUG 34 | formatter=simpleFormatter 35 | args=(sys.stdout,) 36 | 37 | [handler_rotateFileHandler] 38 | class=handlers.RotatingFileHandler 39 | level=DEBUG 40 | formatter=simpleFormatter 41 | args=('/tmp/chat.log', 'a', 200000, 9) 42 | 43 | [handler_NotifyHandler] 44 | class=handlers.RotatingFileHandler 45 | level=DEBUG 46 | formatter=simpleFormatter1 47 | args=('/tmp/inputnotify.log', 'a', 200000, 9) 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding:utf-8 -*- 3 | 4 | from redis import Redis 5 | import struct, readline, re 6 | 7 | import colorama;colorama.init() 8 | from colorama import Fore 9 | 10 | ''' 11 | 1. 发送消息给朋友 12 | 2. 可自动完成朋友列表, Tab 键选择 13 | 3. 可查找朋友 14 | 4. 不写则直接发往最后选择的朋友 15 | 16 | 发送数据的数据结构 17 | +------------+------------------------+ 18 | | int32 | 消息类型 | 19 | | | 1. 普通消息 | 20 | | | 2. 窗口抖动消息 | 21 | | | 3. 群消息 | 22 | | | 4. 注销消息 | 23 | | | 5. 图片消息 | 24 | +============+========================+ 25 | | int32 | 消息接受者长度 | 26 | +------------+------------------------+ 27 | | int32 | 消息正文长度 | 28 | +------------+------------------------+ 29 | | string | 消息接受者 | 30 | +------------+------------------------+ 31 | | string | 消息正文 | 32 | +------------+------------------------+ 33 | 34 | `注意事项`: 35 | 1. 如果消息类型为注销消息(4 ),则后续字段不填 36 | 2. 消息发送匹配 37 | 38 | 1. to message message 消息可能包含空格 39 | 2. message for prev person 40 | ''' 41 | MESSAGE = 1 42 | SHAKEMESSAGE = 2 43 | GRPMESSAGE = 3 44 | LOGOUTMESSAGE = 4 45 | IMAGEMESSAGE = 5 46 | 47 | class Chat(object): 48 | 49 | def __init__(self): 50 | self.lastfriend = "" 51 | self.conn = Redis(host="localhost", db=10) 52 | self.runflag = True 53 | 54 | def executecmd(self, cmd, param): 55 | if cmd == "shake": 56 | self.sendto(SHAKEMESSAGE, param,'') 57 | self.lastfriend = param 58 | elif cmd == "to": 59 | self.lastfriend = param 60 | 61 | elif cmd == "online": 62 | guyscount = 0 63 | for guy in self.conn.hkeys("onlineguys"): 64 | if self.conn.hget("onlineguys", guy) != "offline": 65 | print(guy) 66 | guyscount += 1 67 | 68 | print("在线好友 %s%d%s" % (Fore.GREEN, guyscount, Fore.RESET)) 69 | 70 | elif cmd == "stat": 71 | onlinecount = self.conn.llen("onlinefriends") 72 | guy = self.conn.hget("onlineguys",param) 73 | if guy: 74 | print(Fore.YELLOW + guy + Fore.RESET) 75 | 76 | elif cmd == "image": 77 | if self.lastfriend and param: 78 | self.sendto(IMAGEMESSAGE, self.lastfriend, param) 79 | else: 80 | print(Fore.RED+"请先选择朋友或输入图像路径"+Fore.RESET) 81 | 82 | elif cmd == "brocast": 83 | ''' 84 | {"messagebody":['p1','p2']} 85 | ''' 86 | onlinecount = self.conn.llen("onlinefriends") 87 | for guy in self.conn.lrange("onlinefriends", 0, onlinecount): 88 | to = guy[0:guy.find("-")] 89 | self.sendto(MESSAGE, to, param) 90 | 91 | elif cmd == "quit": 92 | self.runflag = False 93 | self.sendto(LOGOUTMESSAGE, None, None) 94 | elif cmd == "exit": 95 | self.runflag = False 96 | else: 97 | print(Fore.RED + ":quit " + Fore.RESET + "exit client") 98 | print(Fore.RED + ":shake FRIEND " + Fore.RESET + "send shake message to friend") 99 | print(Fore.RED + ":online " + Fore.RESET + "show all online friends") 100 | print(Fore.RED + ":stat FRIEND" + Fore.RESET + " show friends status") 101 | 102 | def parsecmd(self, message): 103 | cmdpattern = re.compile('^(:)(\w*)\s?(.*)$') 104 | msgpattern = re.compile(r'^(\|)?(.*)$') 105 | cmdmatch, msgmatch = cmdpattern.match(message), msgpattern.match(message) 106 | 107 | if cmdmatch: 108 | _, cmd, param = cmdmatch.groups() 109 | self.executecmd(cmd, param) 110 | 111 | elif msgmatch: 112 | prefix, remaintext = msgmatch.groups() 113 | if prefix == "|": 114 | tokens = remaintext.split() 115 | to, body = tokens[0], "".join(tokens[1:]) 116 | else: 117 | to, body = self.lastfriend, remaintext 118 | 119 | if body =='': 120 | return 121 | 122 | msgtype = GRPMESSAGE if to.find("_") > -1 else MESSAGE 123 | self.sendto(msgtype, to, body) 124 | 125 | def sendto(self, msgtype, to, message): 126 | 127 | bytemsg = "" 128 | 129 | if msgtype == LOGOUTMESSAGE: 130 | bytemsg = struct.pack("i", 4) 131 | else: 132 | if not to: return 133 | tolen, messagelen = len(to), len(message) 134 | 135 | if msgtype in (MESSAGE, GRPMESSAGE, IMAGEMESSAGE): 136 | bytemsg = struct.pack("iii%ss%ss" % (tolen, messagelen), msgtype, tolen, messagelen, to, message) 137 | 138 | elif msgtype == SHAKEMESSAGE: 139 | bytemsg = struct.pack("ii%ss" % tolen, msgtype, tolen, to) 140 | 141 | self.conn.lpush("messagepool", bytemsg) 142 | 143 | def getfriends(self): 144 | 145 | self.friendsinfo = self.conn.lrange("friends", 0, self.conn.llen("friends")) 146 | self.groupsinfo = self.conn.lrange("groups", 0, self.conn.llen("groups")) 147 | self.friendsinfo.extend(self.groupsinfo) 148 | return self 149 | 150 | def completer(self, prefix, index): 151 | matches = [friend for friend in self.friendsinfo if friend.startswith(prefix)] 152 | try: 153 | return matches[index] 154 | except IndexError: 155 | pass 156 | 157 | def chat(self): 158 | 159 | readline.parse_and_bind("tab:complete") 160 | readline.set_completer(self.completer) 161 | while self.runflag: 162 | message = raw_input("=>%s: " % (self.lastfriend)) 163 | self.parsecmd(message) 164 | print("") 165 | 166 | if __name__ == '__main__': 167 | 168 | Chat().getfriends().chat() 169 | -------------------------------------------------------------------------------- /src/qqsetting.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #-*-coding:utf-8 -*- 3 | # 4 | #Author: tony - birdaccp at gmail.com 5 | #Create by:2014-07-23 16:56:18 6 | #Last modified:2014-07-23 18:00:37 7 | #Filename:qqsetting.py 8 | #Description: 9 | CARE_ALL = False 10 | CARE_FRIENDS = [] 11 | 12 | FACEDIR = "face" 13 | FILEDIR = "download" 14 | ENABLE_OSD = True 15 | 16 | -------------------------------------------------------------------------------- /src/webqq.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #-*-coding:utf-8 -*- 3 | # 4 | #Author: alex8224@gmail.com birdaccp@gmail.com 5 | #Create by:2014-07-23 17:58:44 6 | #Last modified:2014-07-23 18:00:09 7 | #Filename:webqq.py 8 | #Description: webqq-cli v0.2 9 | 10 | ''' 11 | 使用WEBQQ3.0协议 12 | ''' 13 | import struct 14 | import requests 15 | import traceback 16 | import qqsetting 17 | import gevent, greenlet 18 | import random, json, time, os 19 | import logging,logging.config 20 | import urllib, cookielib 21 | from colorama import init, Fore;init() 22 | from gevent import monkey, queue, pool;monkey.patch_all(dns = False) 23 | 24 | WEBQQ_APPID = 1003903 25 | WEBQQ_VERSION = 'WebQQ3.0' 26 | 27 | def rmfile(filepath): 28 | if os.path.exists(filepath): 29 | os.unlink(filepath) 30 | 31 | def processopen(param): 32 | import subprocess 33 | handler = subprocess.Popen(param, 34 | shell = True, 35 | stdout = subprocess.PIPE 36 | ) 37 | retcode = handler.wait() 38 | return retcode, handler 39 | 40 | def formatdate(millseconds): 41 | return time.strftime( 42 | "%Y-%m-%d %H:%M:%S", 43 | time.localtime(long(millseconds)) 44 | ) 45 | 46 | def getLogger(loggername = "root"): 47 | logging.config.fileConfig( os.path.join( os.getcwd(),"chatloggin.conf") ) 48 | return logging.getLogger() 49 | 50 | def ctime(): 51 | return str( int( time.time() ) ) 52 | 53 | def localetime(): 54 | return time.strftime("%Y-%m-%d %H:%M:%S") 55 | 56 | def textoutput(msgtype, messagetext): 57 | import re 58 | highlightre = re.match('(.+ )\[(.+)\](.+)', messagetext) 59 | if highlightre: 60 | prefix, who, message = highlightre.groups() 61 | 62 | if msgtype == 1: 63 | getLogger().info( 64 | Fore.GREEN + 65 | prefix + 66 | who + 67 | Fore.YELLOW+ 68 | message + 69 | Fore.RESET + "\n") 70 | 71 | if msgtype == 2: 72 | getLogger().info( 73 | Fore.BLUE + 74 | who + 75 | Fore.RESET + 76 | message ) 77 | 78 | if msgtype == 3: 79 | getLogger().info( 80 | Fore.GREEN + 81 | prefix + 82 | Fore.RED + 83 | who + 84 | Fore.RESET + 85 | message ) 86 | 87 | if msgtype == 4: 88 | getLogger().info( 89 | Fore.YELLOW + 90 | prefix + 91 | who + 92 | Fore.GREEN + 93 | message + 94 | Fore.RESET + "\n") 95 | 96 | else: 97 | getLogger().info(messagetext) 98 | 99 | class NotifyOsd(object): 100 | def __init__(self): 101 | try: 102 | self.pynotify = __import__("pynotify") 103 | self.pynotify.init("webqq") 104 | except ImportError: 105 | pass 106 | 107 | def notify(self, notifytext, timeout = 3, icon = None, title = "通知"): 108 | if not self.pynotify: 109 | return 110 | 111 | reload(qqsetting) 112 | if qqsetting.ENABLE_OSD: 113 | notifyins = self.pynotify.Notification(title, notifytext, icon) 114 | notifyins.set_timeout(timeout*1000) 115 | notifyins.show() 116 | 117 | notifyer = NotifyOsd() 118 | 119 | class MsgCounter(object): 120 | 121 | def __init__(self): 122 | self.msgindex = random.randint(1, 99999999) 123 | 124 | def get(self): 125 | self.msgindex+=1 126 | return self.msgindex 127 | 128 | MessageIndex = MsgCounter() 129 | 130 | 131 | 132 | class WebQQException(Exception):pass 133 | 134 | class MessageHandner(object): 135 | ''' 对消息进行处理 ''' 136 | 137 | def __init__(self, context): 138 | 139 | self.context = context 140 | self.logger = getLogger(loggername = "buddies_status") 141 | 142 | def dispatch(self, msgtype, message): 143 | 144 | prefixfunc= "on_" + msgtype 145 | func = getattr(self, prefixfunc) if hasattr(self, prefixfunc) else None 146 | 147 | if func: 148 | func(message) 149 | 150 | def __joinmessage(self, message): 151 | 152 | messagebody = "".join( 153 | map( 154 | lambda item: 155 | ":face" + str(item[1]) + ": " 156 | if isinstance(item, list) else item, message) 157 | ) 158 | 159 | return messagebody.encode("utf-8") 160 | 161 | def on_message(self, message): 162 | 163 | fromwho = self.context.get_user_info(message["from_uin"]) 164 | mess = message["content"][1:] 165 | 166 | sendtime = formatdate(message["time"]) 167 | 168 | messagebody = self.__joinmessage(mess) 169 | for msg in mess: 170 | if isinstance(msg, list): 171 | msgtype = msg[0] 172 | if msgtype == "offpic": 173 | content = msg[1] 174 | picpath = content["file_path"] 175 | 176 | self.context.spawn( 177 | picpath, 178 | str(message["from_uin"]), 179 | task = self.context.downoffpic 180 | ) 181 | 182 | elif msgtype == "cface": 183 | to, guid, _ = str(message["from_uin"]), msg[1], msg[2] 184 | self.context.spawn(to, guid, task = self.context.downcface) 185 | 186 | faceuri = self.context.getface(message["from_uin"]) 187 | 188 | notifyer.notify( 189 | "".join(messagebody).encode("utf-8"), 190 | title = fromwho, 191 | timeout= 5, 192 | icon = faceuri 193 | ) 194 | 195 | textoutput(1,"%s [%s] 说 %s" % ( sendtime, fromwho, messagebody ) ) 196 | 197 | def on_group_message(self, message): 198 | 199 | groupcode = message["group_code"] 200 | fromwho = self.context.get_member_by_code(message["send_uin"]) 201 | mess = message["content"][1:] 202 | 203 | sendtime = formatdate(message["time"]) 204 | 205 | messagebody = self.__joinmessage(mess) 206 | messagebody = "%s [%s] 说 %s" % ( 207 | sendtime + " " + self.context.get_groupname_by_code(groupcode), 208 | fromwho, 209 | messagebody 210 | ) 211 | 212 | for msg in mess: 213 | if isinstance(msg, list): 214 | msgtype = msg[0] 215 | msgcontent = msg[1] 216 | if msgtype == "cface": 217 | gid, uin = message["group_code"], message["send_uin"] 218 | fid = message["info_seq"] 219 | filename = msgcontent["name"] 220 | pichost, hostport = msgcontent["server"].split(":") 221 | vfwebqq = self.context.vfwebqq 222 | self.context.spawn( 223 | str(gid), str(uin), pichost, hostport, 224 | str(fid), filename, vfwebqq, 225 | task = self.__downgrppic 226 | ) 227 | 228 | 229 | textoutput(3,messagebody) 230 | 231 | def __downgrppic(self, gin, uin, host, port, fid, filename,vfwebqq): 232 | '''下载群图片''' 233 | 234 | grouppicurl = "http://webqq.qq.com/cgi-bin/get_group_pic?type=0&gid=%s&uin=%s&rip=%s&rport=%s&fid=%s=&pic=%s&vfwebqq=&%s&t=%s" 235 | grouppicurl = grouppicurl % (gin, uin, host, port, fid, urllib.quote(filename), vfwebqq, ctime()) 236 | fullpath = os.path.abspath(os.path.join(qqsetting.FILEDIR, filename.replace("{","").replace("}",""))) 237 | cmd = "wget -q -O '%s' '%s'" % (fullpath, grouppicurl) 238 | retcode, handler = processopen(cmd) 239 | 240 | if retcode == 0: 241 | print("\nfile://" + fullpath) 242 | 243 | def on_shake_message(self, message): 244 | 245 | fromwho = self.context.get_user_info(message["from_uin"]) 246 | textoutput(3, "朋友 [%s] 给你发送一个窗口抖动 :)" % fromwho) 247 | self.context.write_message(ShakeMessage(message["from_uin"])) 248 | 249 | def on_kick_message(self, message): 250 | 251 | self.context.logger.info("当前账号已经在别处登陆!") 252 | notifyer.notify("当前账号已经在别处登陆!") 253 | self.context.stop() 254 | 255 | def on_buddies_status_change(self, message): 256 | 257 | fromwho = self.context.get_user_info(message["uin"]) 258 | status = message["status"].encode("utf-8") 259 | 260 | reload(qqsetting) 261 | if status == "offline": 262 | self.context.redisconn.hdel("onlineguys", fromwho) 263 | else: 264 | self.context.redisconn.hset("onlineguys",fromwho, status) 265 | 266 | if qqsetting.CARE_ALL or fromwho in qqsetting.CARE_FRIENDS: 267 | faceuri = self.context.getface(message["uin"]) 268 | logmessage = "%s %s" % (fromwho, status) 269 | notifyer.notify(logmessage, timeout = 2, icon = faceuri) 270 | 271 | def on_input_notify(self, message): 272 | 273 | fromwho = self.context.get_user_info(message["from_uin"]) 274 | textoutput(3, "朋友 [%s] 正在打字......" % fromwho) 275 | 276 | def on_file_message(self, message): 277 | 278 | fromwho = self.context.get_user_info(message["from_uin"]) 279 | if message["mode"] == 'recv': 280 | filename = message["name"].encode("utf-8") 281 | textoutput(2, "朋友 [%s] 发送文件 %s 给你" % (fromwho, filename)) 282 | to, guid = str(message["from_uin"]), urllib.quote(filename) 283 | lcid = str(message["session_id"]) 284 | 285 | self.on_start_transfile(filename) 286 | self.context.spawn( 287 | lcid, 288 | to, 289 | guid, 290 | filename, 291 | task = self.context.recvfile, 292 | linkok = self.on_end_transfile 293 | ) 294 | 295 | elif message["mode"] == "refuse": 296 | textoutput(2, "朋友 [%s] 取消了发送文件" % (fromwho, )) 297 | 298 | def on_start_transfile(self, filename): 299 | notifyer.notify("正在接收文件 %s" % filename) 300 | 301 | def on_end_transfile(self, result): 302 | filename = result.get() 303 | if filename: 304 | notifyer.notify("文件 %s 接收完成" % filename) 305 | else: 306 | notifyer.notify("文件 %s 接收失败 " % filename) 307 | 308 | def __downofflinefile(self, url, filename): 309 | rmfile(filename) 310 | cmd = "wget -q -O '%s' '%s'" % (filename, url) 311 | 312 | retcode, _ = processopen(cmd) 313 | if retcode == 0: 314 | notifyer.notify("离线文件 %s 下载完成 " % filename) 315 | else: 316 | notifyer.notify("离线文件 %s 下载失败" % filename) 317 | rmfile(filename) 318 | 319 | 320 | 321 | def on_push_offfile(self, message): 322 | 323 | rkey, ip, port = message["rkey"], message["ip"], message["port"] 324 | fromwho = self.context.get_user_info(message["from_uin"]) 325 | filename = message["name"] 326 | downurl = "http://%s:%d/%s?ver=2173&rkey=%s" % (ip, port, filename, rkey) 327 | notifyer.notify("开始接受 %s 发的离线文件 %s" % (fromwho, filename)) 328 | self.context.spawn( 329 | downurl, 330 | filename, 331 | task = self.__downofflinefile) 332 | 333 | class QQMessage(object): 334 | 335 | def __init__(self, to, messagetext, context = None): 336 | self.msgtype = 1 337 | self.to = to 338 | self.messagetext = messagetext.encode("utf-8") 339 | self.retrycount = 0 340 | self.context = context 341 | self.url = "http://d.web2.qq.com/channel/send_buddy_msg2" 342 | 343 | def encode(self, clientid, psessionid): 344 | 345 | content = '''["%s",[]]''' 346 | r = json.dumps( 347 | { 348 | "to":self.to, 349 | "face":570, 350 | "content":content % self.messagetext, 351 | "msg_id":MessageIndex.get(), 352 | "clientid":clientid, 353 | "psessionid":psessionid 354 | } ) 355 | 356 | rdict = urllib.quote(r) 357 | return "r=%s&clientid=%s&psessionid=%s" % (rdict, clientid, psessionid) 358 | 359 | def decode(self): 360 | return self.to, self.messagetext 361 | 362 | def sendOk(self, result): 363 | pass 364 | 365 | def sendFailed(self, result): 366 | if self.retrycount <3: 367 | self.context.write_message(self) 368 | self.retrycount += 1 369 | elif self.retrycount == 3: 370 | print str(self), "发送失败" 371 | 372 | def send(self, context, clientid, psessionid): 373 | qqrawmsg = self.encode(clientid, psessionid) 374 | 375 | return context.spawn( 376 | self.url, 377 | qqrawmsg, 378 | task = context.sendpost, 379 | linkok = self.sendOk, 380 | linkfailed = self.sendFailed ) 381 | 382 | def __str__(self): 383 | return "send message to %s, message = %s" % (self.to, self.messagetext) 384 | 385 | class ImageMessage(QQMessage): 386 | '''发送图片''' 387 | 388 | def __init__(self, to, imagefile, context = None): 389 | super(ImageMessage, self).__init__(to, "", context) 390 | self.context = context 391 | self.imagefile = imagefile 392 | 393 | def uploadpic(self): 394 | uploadurl = "http://weboffline.ftn.qq.com/ftn_access/upload_offline_pic?time="+ctime() 395 | formdata = { 396 | "skey" : self.context.skey, 397 | "callback" : "parent.EQQ.Model.ChatMsg.callbackSendPic", 398 | "locallangid" : 2052, 399 | "clientversion" : 1409, 400 | "uin" : self.context.qq, 401 | "appid" : 1002101, 402 | "peeruin" : self.to, 403 | "fileid" : 1, 404 | "vfwebqq" : self.context.vfwebqq, 405 | "senderviplevel" : 0, 406 | "reciverviplevel" : 0, 407 | "filename" : os.path.basename(self.imagefile) 408 | } 409 | 410 | self.formdata = " ".join( 411 | ( 412 | "--form-string '%s=%s'" % (k, str(v)) 413 | for k, v in formdata.iteritems() 414 | ) 415 | ) 416 | 417 | cmd = "curl -s %s -F 'file=@%s' '%s'" % ( 418 | self.formdata, 419 | self.imagefile, 420 | uploadurl ) 421 | 422 | retcode, uploadhandler = processopen(cmd) 423 | 424 | if retcode == 0: 425 | response = uploadhandler.stdout.read() 426 | print(response) 427 | jsonstart, jsonend = response.find("{"), response.find("}") + 1 428 | return json.loads(response[jsonstart:jsonend]) 429 | else: 430 | print cmd 431 | print uploadhandler.stdout.read() 432 | 433 | def encode(self, clientid, psessionid): 434 | upinfo= self.uploadpic() 435 | picpath = upinfo["filepath"] 436 | picname = upinfo["filename"] 437 | picsize = upinfo["filesize"] 438 | 439 | content = '''[["offpic","%s","%s",%d],[]]''' 440 | r = json.dumps( 441 | { 442 | "to":self.to, 443 | "face":570, 444 | "content":content % (picpath, picname, picsize), 445 | "msg_id":MessageIndex.get(), 446 | "clientid":clientid, 447 | "psessionid":psessionid 448 | } ) 449 | rdict = urllib.quote(r) 450 | return "r=%s&clientid=%s&psessionid=%s" %(rdict, clientid, psessionid) 451 | 452 | class GroupMessage(QQMessage): 453 | ''' 454 | 群消息 455 | ''' 456 | def __init__(self, to, messagetext, context=None): 457 | 458 | super(GroupMessage, self).__init__(to, messagetext, context) 459 | self.url = "http://d.web2.qq.com/channel/send_qun_msg2" 460 | 461 | def encode(self, clientid, psessionid): 462 | 463 | groupuin = self.context.get_uin_by_groupname(self.to) 464 | content = '''["%s"]''' % self.messagetext 465 | r = json.dumps( 466 | { 467 | "group_uin":groupuin, 468 | "content": content, 469 | "msg_id":MessageIndex.get(), 470 | "clientid":clientid, 471 | "psessionid":psessionid 472 | }) 473 | rdict = urllib.quote(r) 474 | return "r=" + rdict + "&clientid=" + clientid + "&psessionid=" + psessionid 475 | 476 | def __str__(self): 477 | return "send group message %s to %s " % (self.messagetext, self.to) 478 | 479 | class ShakeMessage(QQMessage): 480 | ''' 481 | 发送窗口抖动消息 482 | ''' 483 | def __init__(self, to): 484 | self.msgtype = 2 485 | self.to = to 486 | self.retrycount = 0 487 | 488 | def sendFailed(self, *args): 489 | print "shake message send failed!" 490 | 491 | def send(self, context, clientid, psessionid): 492 | url = "http://d.web2.qq.com/channel/shake2?to_uin="+str(self.to)\ 493 | +"&clientid="+clientid+"&psessionid="+psessionid+"&t="+ctime() 494 | 495 | return context.spawn( 496 | url, 497 | task = context.sendget, 498 | linkfailed = self.sendFailed ) 499 | 500 | def __str__(self): 501 | return "send shake message to %s" % self.to 502 | 503 | class KeepaliveMessage(QQMessage): 504 | ''' 心跳消息 ''' 505 | 506 | def __init__(self): 507 | self.msgtype = 3 508 | 509 | def sendFailed(self, result):pass 510 | 511 | def send(self, context): 512 | url = "http://webqq.qq.com/web2/get_msg_tip?uin=&tp=1&id=0&retype=1&rc=2&lv=3&t="+ctime() 513 | return context.spawn( 514 | url, 515 | task = context.sendget, 516 | linkfailed = self.sendFailed ) 517 | 518 | class LogoutMessage(QQMessage): 519 | ''' 520 | 注销消息 521 | ''' 522 | def __init__(self): 523 | self.msgtype = 4 524 | 525 | def send(self, context, clientid, psessionid): 526 | logouturl = "http://d.web2.qq.com/channel/logout2?ids=&clientid="\ 527 | +clientid+"&psessionid="+psessionid+"&t="+str(time.time()) 528 | 529 | return context.spawn( 530 | logouturl, 531 | task = context.sendget ) 532 | 533 | class StatusChangeMessage(QQMessage): 534 | '''状态变更消息''' 535 | 536 | def __init__(self, status, who): 537 | self.msgtype = 5 538 | self.status = status 539 | self.who = who 540 | 541 | def encode(self): 542 | pass 543 | 544 | class MessageFactory(object): 545 | 546 | @staticmethod 547 | def getMessage(webcontext, message): 548 | 549 | msgtype = struct.unpack("i", message[:4])[0] 550 | 551 | sendtime = localetime() 552 | if msgtype == 1: 553 | tolen, bodylen = struct.unpack("ii", message[4:12]) 554 | to, body = struct.unpack("%ss%ss" % (tolen, bodylen), message[12:]) 555 | uin = webcontext.get_uin_by_name(to) 556 | textoutput(4, "%s [对%s] 说 %s" % (sendtime, to, body)) 557 | return QQMessage(uin, body.decode("utf-8"), context = webcontext) 558 | 559 | if msgtype == 2: 560 | tolen = struct.unpack("i", message[4:8]) 561 | to = struct.unpack("%ss" % tolen, message[8:]) 562 | to = to[0] 563 | uin = webcontext.get_uin_by_name(to) 564 | return ShakeMessage(uin) 565 | 566 | if msgtype == 3: 567 | tolen, bodylen = struct.unpack("ii", message[4:12]) 568 | to, body = struct.unpack("%ss%ss" % (tolen, bodylen), message[12:]) 569 | textoutput(4,"%s [对%s] 说 %s" % (sendtime, to, body)) 570 | to = to[to.find("_")+1:] 571 | return GroupMessage(to, body.decode("utf-8"), context = webcontext) 572 | 573 | if msgtype == 4: 574 | return LogoutMessage() 575 | 576 | if msgtype == 5: 577 | tolen, bodylen = struct.unpack("ii", message[4:12]) 578 | to, body = struct.unpack("%ss%ss" % (tolen, bodylen), message[12:]) 579 | uin = webcontext.get_uin_by_name(to) 580 | return ImageMessage(uin, body.decode("utf-8"), context = webcontext) 581 | 582 | class WebQQ(object): 583 | def __init__(self, qqno, qqpwd, handler=None): 584 | self.handler = handler if handler else MessageHandner(self) 585 | self.uin = qqno 586 | self.qqpwd = qqpwd 587 | self.ptwebqq = "" 588 | self.psessionid = "" 589 | self.clientid = str(random.randint(1,99999999)) 590 | self.vfwebqq = "" 591 | self.vcode = "" 592 | self.vcode2 = "" 593 | self.cookiefile = "/tmp/cookies.lwp" 594 | self.cookiejar = cookielib.LWPCookieJar(filename=self.cookiefile) 595 | self.fakeid = "" 596 | self.friends = None 597 | self.friendindex = 1 598 | self.uintoqq = {} 599 | self.referurl = "http://d.web2.qq.com/proxy.html?v=20110331002&callback=1&id=2" 600 | self.headers = { 601 | "User-Agent": "Mozilla/5.0 (X11; Linux i686; rv:16.0) Gecko/20100101 Firefox/16.0", 602 | "Referer": "http://d.web2.qq.com/proxy.html?v=20110331002&callback=1&id=2", 603 | "Content-Type": "application/x-www-form-urlencoded" 604 | } 605 | 606 | self.mq = queue.Queue(20) 607 | self.taskpool = pool.Pool(10) 608 | self.runflag = False 609 | from redis import Redis 610 | self.redisconn = Redis(host="localhost", db=10) 611 | self.logger = getLogger() 612 | 613 | self.session = requests.Session() 614 | self.session.headers = self.headers 615 | 616 | def add_cookie(self, key, value, domain, path='/'): 617 | version = 0 618 | name = key 619 | port = None 620 | port_specified=None 621 | domain, domain_specified, domain_initial_dot=domain, None, None 622 | path, path_specified = path,None 623 | secure = None 624 | expires = None 625 | discard = None 626 | comment = None 627 | comment_url = None 628 | rest = {} 629 | c = cookielib.Cookie(version, 630 | name, value, 631 | port, port_specified, 632 | domain, domain_specified, domain_initial_dot, 633 | path, path_specified, 634 | secure, 635 | expires, 636 | discard, 637 | comment, 638 | comment_url, 639 | rest) 640 | self.cookiejar.set_cookie(c) 641 | 642 | def save_cookies(self, cookiejar): 643 | for c in cookiejar: 644 | args = dict(vars(c).items()) 645 | args['rest'] = args['_rest'] 646 | del args['_rest'] 647 | c = cookielib.Cookie(**args) 648 | self.cookiejar.set_cookie(c) 649 | self.cookiejar.save(ignore_discard=True) 650 | 651 | @property 652 | def load_cookies(self): 653 | self.cookiejar.load(ignore_discard=True) 654 | 655 | @property 656 | def get_hash(self): 657 | a = self.ptwebqq +"password error" 658 | k = "" 659 | while True: 660 | if len(k) <= len(a): 661 | k += self.uin; 662 | if len(k) == len(a): break 663 | else: 664 | k = k[0:len(a)] 665 | break 666 | E = [ord(k[c]) ^ ord(a[c]) for c in range(len(k))] 667 | a = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"]; 668 | i = ""; 669 | for c in range(len(E)): 670 | i += a[E[c] >> 4 & 15] 671 | i += a[E[c] & 15] 672 | return i 673 | 674 | @property 675 | def get_password(self): 676 | def hexchar2bin(uin): 677 | uin_final = '' 678 | uin = uin.split('\\x') 679 | for i in uin[1:]: 680 | uin_final += chr(int(i, 16)) 681 | return uin_final 682 | 683 | from hashlib import md5 684 | return md5( 685 | md5( 686 | md5(self.qqpwd).digest() 687 | + hexchar2bin(self.vcode2)).hexdigest().upper() 688 | + self.vcode.upper()).hexdigest().upper() 689 | 690 | def build_userinfo(self): 691 | self.friendinfo = {} 692 | self.redisconn.delete("friends") 693 | for friend in self.friends["result"]["marknames"]: 694 | self.redisconn.lpush("friends", friend["markname"]) 695 | self.friendinfo[friend["markname"]] = friend["uin"] 696 | self.friendinfo[friend["uin"]] = friend["markname"] 697 | 698 | for friend in self.friends["result"]["info"]: 699 | if not self.friendinfo.has_key(friend["uin"]): 700 | self.redisconn.lpush("friends", friend["nick"]) 701 | self.friendinfo[friend["nick"]] = friend["uin"] 702 | self.friendinfo[friend["uin"]] = friend["nick"] 703 | 704 | 705 | def build_groupinfo(self): 706 | getgroupurl = "http://s.web2.qq.com/api/get_group_name_list_mask2" 707 | encodeparams = "r=" + urllib.quote(json.dumps({"hash": self.get_hash, "vfwebqq":self.vfwebqq})) 708 | response = self.sendpost( 709 | getgroupurl, 710 | encodeparams, 711 | {"Referer":"http://s.web2.qq.com/proxy.html?v=20110412001&callback=1&id=3"} 712 | ) 713 | 714 | self.logger.debug("获取群信息......") 715 | self.groupinfo = {} 716 | if response["retcode"] !=0: 717 | raise WebQQException("get group info failed!") 718 | 719 | grouplist = response["result"]["gnamelist"] 720 | self.redisconn.delete("groups") 721 | self.groupmemsinfo = {} 722 | for group in grouplist: 723 | self.groupinfo[group["code"]] = group 724 | self.groupinfo[group["name"]] = group 725 | self.redisconn.lpush("groups","%d_%s" % (self.friendindex, group["name"])) 726 | self.friendindex +=1 727 | getgroupinfourl = "http://s.web2.qq.com/api/get_group_info_ext2?gcode=%s&vfwebqq=%s&t=%s" 728 | header = {"Referer":"http://s.web2.qq.com/proxy.html?v=20110412001&callback=1&id=1"} 729 | groupinfo = self.sendget(getgroupinfourl % (group["code"], self.vfwebqq, ctime()), headers = header) 730 | try: 731 | membersinfo = groupinfo["result"]["minfo"] 732 | [self.groupmemsinfo.update({member["uin"]:member["nick"].decode("utf-8")}) for member in membersinfo] 733 | except: 734 | pass 735 | 736 | return self 737 | 738 | def login(self): 739 | login_sig_url = 'https://ui.ptlogin2.qq.com/cgi-bin/login?daid=164&target=self&style=5&mibao_css=m_webqq&appid=1003903&enable_qlogin=0&no_verifyimg=1&s_url=http%3A%2F%2Fweb2.qq.com%2Floginproxy.html&f_url=loginerroralert&strong_login=0&login_state=10&t=20131202001' 740 | self.session.headers['Referer'] = "http://web1.qq.com/webqq.html" 741 | response = self.session.get(login_sig_url) 742 | self.save_cookies(response.cookies) 743 | 744 | f1='var g_login_sig=encodeURIComponent("' 745 | f2='");' 746 | content = response.text 747 | pos1=content.find(f1) 748 | pos2=content.find(f2, pos1) 749 | self.login_sig = content[pos1+len(f1):pos2] 750 | 751 | verifyURL = 'https://ssl.ptlogin2.qq.com/check?uin=%(uin)s&appid=%(appid)s&js_ver=10079&js_type=0&login_sig=%(login_sig)s&u1=http%%3A%%2F%%2Fweb2.qq.com%%2Floginproxy.html&r=%(randstamp)s' % {'uin':self.uin, 'appid':WEBQQ_APPID, 'randstamp':random.random(), 'login_sig':self.login_sig} 752 | 753 | response = self.session.get(verifyURL) 754 | self.save_cookies(response.cookies) 755 | 756 | content=response.text.split(',') 757 | retcode = content[0][-2:-1] 758 | self.vcode = content[1][1:-1] 759 | self.vcode2 = content[2].split("'")[1] 760 | if retcode !='0': 761 | raise WebQQException("Get VCODE Failed!") 762 | return self 763 | 764 | def login1(self): 765 | loginURL='https://ssl.ptlogin2.qq.com/login?u='+ self.uin +'&p='+ self.get_password +'&verifycode='+ self.vcode +'&webqq_type=10&remember_uin=1&login2qq=0&aid=1003903&u1=http%3A%2F%2Fweb2.qq.com%2Floginproxy.html%3Flogin2qq%3D0%26webqq_type%3D10&h=1&ptredirect=0&ptlang=2052&daid=164&from_ui=1&pttype=1&dumy=&fp=loginerroralert&action=4-19-23387&mibao_css=m_webqq&t=1&g=1&js_type=0&js_ver=10079&login_sig='+ self.login_sig +'&pt_uistyle=5' 766 | 767 | 768 | response=self.session.get(loginURL, headers=self.headers) 769 | self.save_cookies(response.cookies) 770 | 771 | content = response.text 772 | 773 | retcode, _, _, _, tip, nickname = eval(content[6:-3]) 774 | if retcode != '0': 775 | raise WebQQException(tip) 776 | 777 | for index,cookie in enumerate(self.cookiejar): 778 | if cookie.name == 'ptwebqq': 779 | self.ptwebqq = cookie.value 780 | elif cookie.name == 'skey': 781 | self.skey = cookie.value 782 | elif cookie.name == 'ptcz': 783 | self.ptcz = cookie.value 784 | elif cookie.name == 'uin': 785 | self.uincode = cookie.value[1:] 786 | 787 | check_url = content.split("','")[2] 788 | if check_url.startswith('http'): 789 | response = self.session.get(check_url, allow_redirects=False) 790 | self.save_cookies(response.cookies) 791 | if response.text.encode('latin1').decode('utf8').strip() != "0": 792 | raise WebQQException("get pt4_token error") 793 | else: 794 | raise WebQQException("valid username,password error") 795 | 796 | return self 797 | 798 | def login2(self): 799 | login2url = "http://d.web2.qq.com/channel/login2" 800 | rdict = json.dumps({ 801 | "status" : "online", 802 | "ptwebqq" : self.ptwebqq, 803 | "passwd_sig" : "", 804 | "clientid" : self.clientid, 805 | "psessionid" : None} 806 | ) 807 | 808 | post_data = "r=%s&clientid=%s&psessionid=null" %(urllib.quote(rdict), self.clientid) 809 | try: 810 | self.session.headers['Referer'] = 'http://d.web2.qq.com/proxy.html?v=20110331002&callback=1&id=3' 811 | with gevent.Timeout(30): 812 | response = json.loads(self.session.post(login2url, data=post_data).text, encoding='utf-8') 813 | if response["retcode"] !=0: 814 | raise WebQQException( 815 | "login2 failed! errcode=%s, errmsg=%s" % 816 | ( response["retcode"], response["errmsg"] ) 817 | ) 818 | 819 | self.vfwebqq = response["result"]["vfwebqq"] 820 | self.psessionid = response["result"]["psessionid"] 821 | self.fakeid = response["result"]["uin"] 822 | self.logger.info("登陆成功!") 823 | return self 824 | 825 | except ValueError: 826 | raise WebQQException("login2 json format error") 827 | 828 | except gevent.timeout.Timeout: 829 | raise WebQQException("login2 timeout") 830 | 831 | def get_friends(self): 832 | url = "http://s.web2.qq.com/api/get_user_friends2" 833 | r = json.dumps({ 834 | "h": "hello", 835 | "hash": self.get_hash, 836 | "vfwebqq": self.vfwebqq 837 | }) 838 | post_data = urllib.quote("r=%s" % r, safe='=') 839 | headers = {"Referer":"http://s.web2.qq.com/proxy.html?v=20110412001&callback=1&id=1"} 840 | self.friends = self.sendpost(url, post_data, headerdict=headers) 841 | self.build_userinfo() 842 | 843 | if self.friends["retcode"]!=0: 844 | raise WebQQException("get_friends failed") 845 | self.logger.info("获取朋友列表...") 846 | return self 847 | 848 | def write_message(self, qqmsg): 849 | try: 850 | self.mq.put_nowait(qqmsg) 851 | except gevent.queue.Full: 852 | self.logger.error("%s 发送失败, 队列已满" % str(qqmsg)) 853 | 854 | def sendpost(self, url, message, headerdict = None, timeoutsecs = 60): 855 | if headerdict: 856 | for k,v in headerdict.iteritems(): 857 | self.session.headers[k] = v 858 | try: 859 | with gevent.Timeout(timeoutsecs): 860 | return json.loads( self.session.post(url, data=message).text) 861 | except ValueError: 862 | raise WebQQException("json format error") 863 | except gevent.timeout.Timeout: 864 | raise WebQQException("sendpost timeout") 865 | except : 866 | raise WebQQException(traceback.print_exc()) 867 | 868 | 869 | def requestwithcookie(self): 870 | return self.session 871 | 872 | def sendget(self, url, headers = {}): 873 | from httplib import BadStatusLine 874 | with gevent.Timeout(30, False): 875 | try: 876 | for headername, headervalue in headers.iteritems(): 877 | self.session.headers[headername] = headervalue 878 | return json.loads(self.session.get(url).text) 879 | except ValueError: 880 | raise WebQQException("json format error") 881 | except BadStatusLine: 882 | raise WebQQException("http statu code error") 883 | 884 | def recvfile(self, lcid, to, guid, filename): 885 | recvonlineurl = "http://d.web2.qq.com/channel/get_file2?lcid=" + lcid + \ 886 | "&guid=" + guid+"&to=" + to + "&psessionid=" + self.psessionid + \ 887 | "&count=1&time=1349864752791&clientid=" + self.clientid 888 | basefilename = filename 889 | filename = filename.replace("(","[").replace(")","]") 890 | filename = os.path.abspath(os.path.join(qqsetting.FILEDIR, filename)) 891 | cmd = "wget -q -O '%s' --referer='%s' --cookies=on --load-cookies=%s --keep-session-cookies '%s'" 892 | 893 | retcode, wgethandler = processopen( 894 | cmd % ( 895 | filename.decode("utf-8"), 896 | self.referurl, 897 | self.cookiesfile, 898 | recvonlineurl 899 | )) 900 | 901 | return basefilename if retcode == 0 else False 902 | 903 | def poll_online_friends(self): 904 | geturl = "http://d.web2.qq.com/channel/get_online_buddies2?clientid=%s&psessionid=%s&t=1349932882032" 905 | try: 906 | onlineguys = json.loads(self.session.get(geturl % (self.clientid, self.psessionid)).text) 907 | if not onlineguys: 908 | return 909 | 910 | retcode, result = onlineguys["retcode"], onlineguys["result"] 911 | if retcode == 0 and result: 912 | batch = self.redisconn.pipeline(transaction = False) 913 | self.redisconn.delete("onlineguys") 914 | for guy in result: 915 | markname = self.get_user_info(guy["uin"]) 916 | self.redisconn.hset("onlineguys", markname, guy["status"]) 917 | batch.execute() 918 | 919 | except WebQQException: 920 | pass 921 | 922 | def downcface(self, to, guid): 923 | lcid = str(MessageIndex.get()) 924 | getcfaceurl = "http://d.web2.qq.com/channel/get_cface2?lcid="+ lcid +\ 925 | "&guid=" + guid + "&to=" + to + "&count=5&time=1&clientid=" + \ 926 | self.clientid + "&psessionid=" + self.psessionid 927 | def sendrequest(): 928 | response = "" 929 | try: 930 | response = self.requestwithcookie().get( 931 | getcfaceurl, 932 | timeout = 300 933 | ).text 934 | try: 935 | print json.loads(response) 936 | return False 937 | except: 938 | pass 939 | 940 | filename = os.getcwd() + "/" + qqsetting.FILEDIR + "/" + guid 941 | with open(filename, "w") as cface: 942 | cface.write(response) 943 | 944 | textoutput(3, "file://%s " % filename) 945 | return True 946 | except: 947 | return False 948 | 949 | for count in range(3): 950 | if sendrequest():break 951 | else: 952 | self.logger.debug("retry downcface %d times" % count) 953 | gevent.sleep(0) 954 | 955 | def getqqnumber(self, uin): 956 | 957 | qqnumber = self.uintoqq.get(uin, None) 958 | if qqnumber: 959 | return qqnumber 960 | 961 | geturl = "http://s.web2.qq.com/api/get_friend_uin2?tuin=%s&verifysession=&type=1&code=&vfwebqq=%s&t=%s" 962 | try: 963 | geturl = geturl % (uin, self.vfwebqq, str(time.time())) 964 | header = {"Referer":"http://s.web2.qq.com/proxy.html?v=20110412001&callback=1&id=1"} 965 | response = self.sendget(geturl,headers = header) 966 | if response["retcode"] == 0: 967 | qqnumber = response["result"]["account"] 968 | self.uintoqq[uin] = qqnumber 969 | return qqnumber 970 | except Exception: 971 | import traceback;traceback.print_exc() 972 | 973 | 974 | def getface(self, uin): 975 | qqnumber = self.getqqnumber(uin) 976 | if qqnumber: 977 | face = "%s/%s.jpg" % (os.getcwd()+"/"+qqsetting.FACEDIR, qqnumber) 978 | if os.path.exists(face): 979 | return face 980 | 981 | getfaceurl = "http://face4.qun.qq.com/cgi/svr/face/getface?cache=0&type=1&fid=0&uin=%s&vfwebqq=%s" 982 | try: 983 | response = self.session.get(getfaceurl % (uin, self.vfwebqq), stream=True) 984 | with open(face, "w") as facefile: 985 | facefile.write(response.raw.read()) 986 | return face 987 | except: 988 | import traceback 989 | 990 | traceback.print_exc() 991 | self.logger.error("download %s failed" % getfaceurl) 992 | 993 | def downoffpic(self, url, fromuin): 994 | getoffpicurl = "http://d.web2.qq.com/channel/get_offpic2?file_path=" + \ 995 | urllib.quote(url) + "&f_uin=" + fromuin + "&clientid=" + \ 996 | self.clientid + "&psessionid=" + self.psessionid 997 | try: 998 | 999 | response = self.session.get(getoffpicurl, stream=True) 1000 | fd = response.raw 1001 | filename = os.getcwd() + "/" + qqsetting.FILEDIR + "/" + url[1:] + ".jpg" 1002 | with open(filename, "w") as offpic: 1003 | offpic.write(fd.read()) 1004 | 1005 | textoutput(3, "file://%s " % filename) 1006 | 1007 | except: 1008 | import traceback 1009 | 1010 | traceback.print_exc() 1011 | self.logger.error("download %s failed" % getoffpicurl) 1012 | 1013 | def send_message(self): 1014 | 1015 | while self.runflag: 1016 | try: 1017 | message = self.redisconn.lpop("messagepool") 1018 | if message: 1019 | qqmesg = MessageFactory.getMessage(self, message) 1020 | 1021 | if isinstance(qqmesg, LogoutMessage): 1022 | print "logout message" 1023 | self.stop() 1024 | continue 1025 | 1026 | qqmesg.send(self, self.clientid, self.psessionid) 1027 | 1028 | innermsg = self.mq.get_nowait() 1029 | 1030 | if isinstance(innermsg, KeepaliveMessage): 1031 | innermsg.send(self) 1032 | else: 1033 | innermsg.send(self, self.clientid, self.psessionid) 1034 | 1035 | gevent.sleep(0.1) 1036 | 1037 | except gevent.queue.Empty: 1038 | gevent.sleep(0.1) 1039 | 1040 | except greenlet.GreenletExit: 1041 | self.logger.info("send_message exitting......") 1042 | break 1043 | except: 1044 | import traceback 1045 | traceback.print_exc() 1046 | self.stop() 1047 | 1048 | def poll_message(self): 1049 | poll_url = "http://d.web2.qq.com/channel/poll2" 1050 | rdict = json.dumps( 1051 | { 1052 | "clientid":self.clientid, 1053 | "psessionid":self.psessionid, 1054 | "key":0,"ids":[] 1055 | } 1056 | ) 1057 | 1058 | encodeparams = "r=" + urllib.quote(rdict) + "&clientid=" +\ 1059 | self.clientid + "&psessionid=" + self.psessionid 1060 | 1061 | while self.runflag: 1062 | try: 1063 | response = self.sendpost(poll_url, encodeparams, timeoutsecs=30) 1064 | retcode = response["retcode"] 1065 | 1066 | if retcode == 0: 1067 | result = response["result"] 1068 | for message in result: 1069 | poll_type, value = message["poll_type"], message["value"] 1070 | self.handler.dispatch(poll_type, value) 1071 | 1072 | elif retcode == 102: 1073 | print "没收到消息,超时..." 1074 | 1075 | except WebQQException: 1076 | pass 1077 | 1078 | except greenlet.GreenletExit: 1079 | self.logger.info("poll_message exitting......") 1080 | break 1081 | 1082 | except Exception: 1083 | import traceback;traceback.print_exc() 1084 | 1085 | def keepalive(self): 1086 | gevent.sleep(0) 1087 | while self.runflag: 1088 | gevent.sleep(60) 1089 | try: 1090 | 1091 | self.write_message(KeepaliveMessage()) 1092 | 1093 | except greenlet.GreenletExit: 1094 | self.logger.info("Keepalive exitting......") 1095 | break 1096 | 1097 | def get_user_info(self, uin): 1098 | return self.friendinfo.get(uin, str(uin)).encode("utf-8") 1099 | 1100 | def get_uin_by_name(self, name): 1101 | return self.friendinfo.get(name.decode("utf-8"), None) 1102 | 1103 | def get_groupname_by_code(self, code): 1104 | groupinfo = self.groupinfo.get(code, None) 1105 | if groupinfo: 1106 | return groupinfo["name"].encode("utf-8") 1107 | else: 1108 | return code 1109 | 1110 | def get_member_by_code(self, code): 1111 | return self.groupmemsinfo.get(code, code) 1112 | 1113 | def get_uin_by_groupname(self, groupname): 1114 | groupinfo = self.groupinfo.get(groupname.decode("utf-8"), None) 1115 | 1116 | if groupinfo: 1117 | return groupinfo["gid"] 1118 | 1119 | def start(self): 1120 | if not os.path.exists(qqsetting.FACEDIR): 1121 | os.mkdir(qqsetting.FACEDIR) 1122 | if not os.path.exists(qqsetting.FILEDIR): 1123 | os.mkdir(qqsetting.FILEDIR) 1124 | 1125 | self.runflag = True 1126 | self.login().login1().login2().get_friends().build_groupinfo() 1127 | self.taskpool.spawn(self.send_message) 1128 | self.taskpool.spawn(self.poll_message) 1129 | self.taskpool.spawn(self.poll_online_friends) 1130 | 1131 | self.installsignal() 1132 | self.taskpool.join() 1133 | 1134 | def stop(self): 1135 | self.logout() 1136 | self.runflag = False 1137 | self.taskpool.kill() 1138 | self.taskpool.join() 1139 | 1140 | def spawn(self, *args, **kwargs): 1141 | 1142 | glet = gevent.spawn(kwargs["task"], *args) 1143 | 1144 | if kwargs.get("linkok"): 1145 | glet.link(kwargs["linkok"]) 1146 | if kwargs.get("linkfailed"): 1147 | glet.link_exception(kwargs["linkfailed"]) 1148 | 1149 | return glet 1150 | 1151 | def installsignal(self): 1152 | import signal 1153 | gevent.signal(signal.SIGTERM, self.stop) 1154 | gevent.signal(signal.SIGINT, self.stop) 1155 | 1156 | def logout(self): 1157 | LogoutMessage().send(self, self.clientid, self.psessionid).get() 1158 | 1159 | if __name__ == '__main__': 1160 | from getpass import getpass 1161 | username = raw_input("Username:") 1162 | password = getpass("Password:") 1163 | qq = WebQQ(username, password) 1164 | try: 1165 | qq.start() 1166 | except WebQQException, ex: 1167 | print(str(ex)) 1168 | --------------------------------------------------------------------------------